earnrate 0.2.3 → 0.2.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 +12 -11
- package/data/CHANGELOG.md +35 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
- package/data/merchants/overrides.yaml +8 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +1 -1
- package/dist/cli/explore.js +1 -1
- package/dist/cli/import.js +4 -3
- package/dist/cli/run.js +1 -1
- package/dist/data/integrity.js +10 -0
- package/dist/data/wallet-edit.js +8 -2
- package/dist/engine/cheat-sheet.js +44 -2
- package/dist/engine/explore.js +14 -5
- package/dist/engine/memberships.js +45 -0
- package/dist/engine/offers.js +22 -2
- package/dist/engine/recommend.js +78 -31
- package/dist/engine/setup.js +22 -3
- package/dist/engine/simulate.js +32 -10
- package/dist/engine/text.js +51 -0
- package/dist/engine/wallet-change.js +25 -4
- package/dist/engine/wallet-summary.js +62 -34
- package/dist/format/card-text.js +7 -13
- package/dist/format/cards.js +7 -8
- package/dist/format/explore.js +17 -8
- package/dist/format/offers.js +5 -9
- package/dist/format/recommendation.js +11 -5
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +12 -19
- package/dist/mcp/inputs.js +2 -2
- package/dist/mcp/schemas.js +13 -0
- package/dist/mcp/tools/account.js +27 -9
- package/dist/mcp/tools/answers.js +24 -7
- package/dist/mcp/tools/wallet.js +49 -7
- package/dist/schema/card.js +14 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
|
@@ -6,7 +6,7 @@ import { withValuationProfile } from "../../engine/profiles.js";
|
|
|
6
6
|
import { recommend } from "../../engine/recommend.js";
|
|
7
7
|
import { searchCards } from "../../engine/setup.js";
|
|
8
8
|
import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../../engine/simulate.js";
|
|
9
|
-
import { currencyName } from "../../engine/text.js";
|
|
9
|
+
import { categoryPhrase, currencyName, NETWORK_NAMES, } from "../../engine/text.js";
|
|
10
10
|
import { EMPTY_WALLET } from "../../engine/transient.js";
|
|
11
11
|
import { formatCardInfo } from "../../format/cards.js";
|
|
12
12
|
import { formatCheatSheet } from "../../format/cheat-sheet.js";
|
|
@@ -16,6 +16,14 @@ import { formatRecommendation } from "../../format/recommendation.js";
|
|
|
16
16
|
import { ALL_CARDS_NOTE, fail } from "../context.js";
|
|
17
17
|
import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
18
18
|
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
|
|
19
|
+
const SOURCES = {
|
|
20
|
+
override: "from earnrate's merchant list",
|
|
21
|
+
seed: "from earnrate's merchant list",
|
|
22
|
+
llm: 'classified by Claude',
|
|
23
|
+
caller: 'from the category given with the request',
|
|
24
|
+
category: 'a kind of purchase, not a store',
|
|
25
|
+
none: 'not recognized',
|
|
26
|
+
};
|
|
19
27
|
const TOP = 10;
|
|
20
28
|
// The tools that answer without a saved wallet: from every card, the cards
|
|
21
29
|
// named in the conversation, or a saved wallet if there is one.
|
|
@@ -24,7 +32,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
24
32
|
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
25
33
|
server.registerTool('best_card_for_purchase', {
|
|
26
34
|
title: 'Best card for a purchase',
|
|
27
|
-
description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. Never needs or returns card numbers.",
|
|
35
|
+
description: "Which card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. Never needs or returns card numbers.",
|
|
28
36
|
inputSchema: z.object({
|
|
29
37
|
merchant: Merchant,
|
|
30
38
|
amount: z
|
|
@@ -55,6 +63,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
55
63
|
country: 'US',
|
|
56
64
|
};
|
|
57
65
|
const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
|
|
66
|
+
const retry = resolved.merchant.source === 'none'
|
|
67
|
+
? `earnrate doesn't recognize "${merchant}", so this answer uses only base rates and may miss the best card. If you can tell what kind of business it is (a restaurant, a grocery store, a gas station), call best_card_for_purchase again with category; otherwise ask the user.\n\n`
|
|
68
|
+
: '';
|
|
58
69
|
if (wallet === null) {
|
|
59
70
|
const explored = exploreCards(catalog, {
|
|
60
71
|
merchant,
|
|
@@ -69,7 +80,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
69
80
|
content: [
|
|
70
81
|
{
|
|
71
82
|
type: 'text',
|
|
72
|
-
text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
83
|
+
text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
73
84
|
},
|
|
74
85
|
],
|
|
75
86
|
structuredContent: {
|
|
@@ -89,7 +100,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
89
100
|
content: [
|
|
90
101
|
{
|
|
91
102
|
type: 'text',
|
|
92
|
-
text: formatRecommendation(output, purchase, { all: true })
|
|
103
|
+
text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
|
|
93
104
|
},
|
|
94
105
|
],
|
|
95
106
|
structuredContent: output,
|
|
@@ -109,7 +120,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
109
120
|
const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
|
|
110
121
|
const m = resolved.merchant;
|
|
111
122
|
const text = [
|
|
112
|
-
`${m.resolvedName}: ${m.category}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence,
|
|
123
|
+
`${m.resolvedName}: ${categoryPhrase(catalog, m.category)}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, ${SOURCES[m.source]}.`,
|
|
124
|
+
m.accepts === null
|
|
125
|
+
? m.source === 'none'
|
|
126
|
+
? 'Which card networks it takes is unknown.'
|
|
127
|
+
: 'Takes all major card networks.'
|
|
128
|
+
: `Takes ${m.accepts.map((n) => NETWORK_NAMES[n] ?? n).join(', ')} only.`,
|
|
129
|
+
...(m.ambiguity ? [m.ambiguity.note] : []),
|
|
113
130
|
...(m.inference
|
|
114
131
|
? [`Claude's reasoning: ${m.inference.rationale}`]
|
|
115
132
|
: []),
|
|
@@ -122,7 +139,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
122
139
|
});
|
|
123
140
|
server.registerTool('compare_welcome_offers', {
|
|
124
141
|
title: 'Compare welcome offers',
|
|
125
|
-
description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
|
|
142
|
+
description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee and any membership the card needs that the user doesn't have. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
|
|
126
143
|
inputSchema: z.object({
|
|
127
144
|
...OfferFilters,
|
|
128
145
|
valuation: CatalogFilters.valuation,
|
|
@@ -197,7 +214,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
197
214
|
});
|
|
198
215
|
server.registerTool('simulate_spending', {
|
|
199
216
|
title: 'Simulate a year of spending',
|
|
200
|
-
description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
|
|
217
|
+
description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees, and minus a membership a card needs (a Costco membership) unless the user has it or spends at Costco. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
|
|
201
218
|
inputSchema: z.object({
|
|
202
219
|
spending: z
|
|
203
220
|
.array(z.object({
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
3
|
+
import { choiceName, money, words } from "../../engine/text.js";
|
|
2
4
|
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
3
5
|
import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
|
|
4
6
|
import { fail } from "../context.js";
|
|
5
7
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
6
8
|
import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
|
|
7
9
|
const WRITE = { readOnlyHint: false, openWorldHint: false };
|
|
10
|
+
// What update_card changed, so the reply confirms the values it saved.
|
|
11
|
+
function changes(args) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const [choice, options] of Object.entries(args.picks ?? {}))
|
|
14
|
+
out.push(`${choiceName(choice)} picks: ${options.map(words).join(', ')}`);
|
|
15
|
+
if (args.checkoutLabel === null)
|
|
16
|
+
out.push('checkout name cleared');
|
|
17
|
+
else if (args.checkoutLabel !== undefined)
|
|
18
|
+
out.push(`checkout name "${args.checkoutLabel}"`);
|
|
19
|
+
if (args.anniversary !== undefined)
|
|
20
|
+
out.push(`anniversary ${args.anniversary}`);
|
|
21
|
+
if (args.activated !== undefined)
|
|
22
|
+
out.push(args.activated.length === 0
|
|
23
|
+
? 'nothing activated'
|
|
24
|
+
: `activated: ${args.activated.join(', ')}`);
|
|
25
|
+
for (const [key, spent] of Object.entries(args.capSpent ?? {}))
|
|
26
|
+
out.push(`${money(spent)} already spent on ${key} this period`);
|
|
27
|
+
if (args.bonus === null)
|
|
28
|
+
out.push('welcome bonus no longer tracked');
|
|
29
|
+
else if (args.bonus !== undefined)
|
|
30
|
+
out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
8
33
|
// The tools that read or change a saved wallet.
|
|
9
34
|
export function registerWalletTools(server, deps, ctx, inputs) {
|
|
10
35
|
const { catalog, store } = deps;
|
|
@@ -27,7 +52,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
27
52
|
});
|
|
28
53
|
server.registerTool('record_purchase', {
|
|
29
54
|
title: 'Record a purchase',
|
|
30
|
-
description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
|
|
55
|
+
description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed.",
|
|
31
56
|
inputSchema: z.object({
|
|
32
57
|
cardId: z
|
|
33
58
|
.string()
|
|
@@ -60,6 +85,8 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
60
85
|
date: date ?? deps.today(),
|
|
61
86
|
};
|
|
62
87
|
const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
|
|
88
|
+
if (resolved.merchant.source === 'none')
|
|
89
|
+
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.`);
|
|
63
90
|
const result = await store.record(purchase, resolved.merchant);
|
|
64
91
|
if (!result.ok)
|
|
65
92
|
return fail(`Couldn't record it: ${result.error}`);
|
|
@@ -104,11 +131,18 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
104
131
|
});
|
|
105
132
|
server.registerTool('update_card', {
|
|
106
133
|
title: 'Update a card',
|
|
107
|
-
description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or
|
|
134
|
+
description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) 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+.',
|
|
108
135
|
inputSchema: CardChange,
|
|
109
136
|
outputSchema: WalletChangeSchema,
|
|
110
137
|
annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
|
|
111
|
-
}, async (args) =>
|
|
138
|
+
}, async (args) => {
|
|
139
|
+
const what = changes(args);
|
|
140
|
+
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
141
|
+
const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
|
|
142
|
+
? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
|
|
143
|
+
: '';
|
|
144
|
+
return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
|
|
145
|
+
});
|
|
112
146
|
server.registerTool('remove_card', {
|
|
113
147
|
title: 'Remove a card',
|
|
114
148
|
description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
|
|
@@ -118,11 +152,19 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
118
152
|
}, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
|
|
119
153
|
server.registerTool('set_memberships', {
|
|
120
154
|
title: 'Set memberships',
|
|
121
|
-
description: "Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier. Replaces the whole list, so include the ones to keep.",
|
|
155
|
+
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.",
|
|
122
156
|
inputSchema: z.object({ memberships: inputs.AllMemberships }),
|
|
123
157
|
outputSchema: WalletChangeSchema,
|
|
124
158
|
annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
|
|
125
|
-
}, async ({ memberships }) =>
|
|
126
|
-
|
|
127
|
-
|
|
159
|
+
}, async ({ memberships }) => {
|
|
160
|
+
const result = await store.setMemberships(memberships);
|
|
161
|
+
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
162
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
163
|
+
return card ? membershipsFor(card) : [];
|
|
164
|
+
}));
|
|
165
|
+
const unused = memberships.filter((m) => !used.has(m));
|
|
166
|
+
return ctx.changed(result, `${memberships.length > 0
|
|
167
|
+
? `Memberships set to ${memberships.join(', ')}.`
|
|
168
|
+
: 'Memberships cleared.'}${unused.length > 0 ? ` ${unused.join(', ')} ${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 });
|
|
169
|
+
});
|
|
128
170
|
}
|
package/dist/schema/card.js
CHANGED
|
@@ -79,6 +79,9 @@ export const WelcomeOfferSchema = z
|
|
|
79
79
|
// the page showed different offers on different visits. The amount
|
|
80
80
|
// above is then the most it shows.
|
|
81
81
|
varies: z.literal(true).optional(),
|
|
82
|
+
// Other cards whose holders the issuer says can't get this offer
|
|
83
|
+
// (Southwest's personal cards, Blue Cash Everyday with Preferred).
|
|
84
|
+
not_with: z.array(KebabId).min(1).optional(),
|
|
82
85
|
note: z.string().optional(),
|
|
83
86
|
})
|
|
84
87
|
.refine((o) => (o.points === undefined) !== (o.dollars === undefined), 'a welcome offer needs points or dollars, not both');
|
|
@@ -143,6 +146,17 @@ export const CardSchema = z
|
|
|
143
146
|
// When the first year differs, usually a waived fee.
|
|
144
147
|
first_year_fee: z.number().nonnegative().optional(),
|
|
145
148
|
welcome_offer: WelcomeOfferSchema.optional(),
|
|
149
|
+
// A paid membership the card can't be had without (Costco Anywhere). Its
|
|
150
|
+
// cost counts like an annual fee for anyone not already paying for it.
|
|
151
|
+
requires_membership: z
|
|
152
|
+
.strictObject({
|
|
153
|
+
id: KebabId,
|
|
154
|
+
name: z.string().min(1), // "a Costco membership"
|
|
155
|
+
annual_fee: z.number().nonnegative(), // the basic tier, dollars
|
|
156
|
+
// Only members can buy here, so spending here means a member.
|
|
157
|
+
members_shop_at: z.array(KebabId).default([]),
|
|
158
|
+
})
|
|
159
|
+
.optional(),
|
|
146
160
|
pools_into: z
|
|
147
161
|
.strictObject({
|
|
148
162
|
currency: CurrencyId,
|
package/dist/schema/wallet.js
CHANGED
|
@@ -42,7 +42,11 @@ export const WalletCardSchema = z.strictObject({
|
|
|
42
42
|
})
|
|
43
43
|
.optional(),
|
|
44
44
|
});
|
|
45
|
+
// The wallet file's format. A file without `version` is version 1; bump it
|
|
46
|
+
// when a change would make older earnrate misread newer files.
|
|
47
|
+
export const WALLET_VERSION = 1;
|
|
45
48
|
export const WalletSchema = z.strictObject({
|
|
49
|
+
version: z.number().int().positive().optional(),
|
|
46
50
|
valuations: z.record(CurrencyId, Valuation).default({}),
|
|
47
51
|
memberships: z.array(KebabId).default([]),
|
|
48
52
|
cards: z.array(WalletCardSchema).min(1),
|
package/package.json
CHANGED
package/wallet.example.yaml
CHANGED