earnrate 0.2.14 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/import.js +14 -11
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +12 -9
- package/dist/engine/checklist.js +4 -9
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +2 -2
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +2 -6
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +24 -21
- package/dist/format/card-text.js +2 -3
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +3 -3
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +10 -6
- package/dist/schema/card.js +6 -10
- package/package.json +2 -1
package/dist/mcp/inputs.js
CHANGED
|
@@ -80,15 +80,15 @@ const cardFields = {
|
|
|
80
80
|
milestoneSpent: z
|
|
81
81
|
.record(z.string(), z.number().nonnegative())
|
|
82
82
|
.optional()
|
|
83
|
-
.describe('Dollars already spent on the card this year, for its spend
|
|
83
|
+
.describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
|
|
84
84
|
enrolled: z
|
|
85
85
|
.array(z.string())
|
|
86
86
|
.optional()
|
|
87
|
-
.describe('
|
|
87
|
+
.describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
|
|
88
88
|
creditUsed: z
|
|
89
89
|
.record(z.string(), z.number().nonnegative())
|
|
90
90
|
.optional()
|
|
91
|
-
.describe('Dollars of a
|
|
91
|
+
.describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
|
|
92
92
|
bonus: z
|
|
93
93
|
.object({
|
|
94
94
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -298,6 +298,7 @@ export const CardSearchSchema = z.object({
|
|
|
298
298
|
network: CardNetwork,
|
|
299
299
|
currency: z.string(),
|
|
300
300
|
annualFee: z.number().nullable(),
|
|
301
|
+
firstYearFee: z.number().nullable(),
|
|
301
302
|
foreignTransactionFee: z.number().nullable(),
|
|
302
303
|
business: z.boolean(),
|
|
303
304
|
onlyAt: z.array(z.string()).nullable(),
|
package/dist/mcp/server.js
CHANGED
|
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
|
|
|
6
6
|
import { registerAccountTools } from "./tools/account.js";
|
|
7
7
|
import { registerAnswerTools } from "./tools/answers.js";
|
|
8
8
|
import { registerWalletTools } from "./tools/wallet.js";
|
|
9
|
-
// The tools that answer without a saved wallet.
|
|
10
9
|
export const ANSWER_TOOLS = [
|
|
11
10
|
'best_card_for_purchase',
|
|
12
11
|
'best_cards_for',
|
|
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
|
|
|
16
15
|
'resolve_merchant',
|
|
17
16
|
'search_cards',
|
|
18
17
|
];
|
|
19
|
-
// The tools that read or change a saved wallet.
|
|
20
18
|
export const SAVED_WALLET_TOOLS = [
|
|
21
19
|
'list_wallet',
|
|
22
20
|
'record_purchase',
|
|
@@ -20,7 +20,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
20
20
|
];
|
|
21
21
|
server.registerTool('export_wallet', {
|
|
22
22
|
title: 'Export the wallet',
|
|
23
|
-
description: 'The saved wallet as a wallet.yaml file
|
|
23
|
+
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.',
|
|
24
24
|
inputSchema: z.object({}),
|
|
25
25
|
outputSchema: z.object({ yaml: z.string() }),
|
|
26
26
|
annotations: {
|
|
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
34
34
|
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
35
35
|
server.registerTool('best_card_for_purchase', {
|
|
36
36
|
title: 'Best card for a purchase',
|
|
37
|
-
description: "Which credit 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. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A
|
|
37
|
+
description: "Which credit 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. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
|
|
38
38
|
inputSchema: z.object({
|
|
39
39
|
merchant: Merchant,
|
|
40
40
|
amount: z
|
|
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
166
166
|
.optional()
|
|
167
167
|
.describe('Include cards already in the wallet.'),
|
|
168
168
|
cards: NamedCards,
|
|
169
|
+
memberships: NamedMemberships,
|
|
169
170
|
}),
|
|
170
171
|
outputSchema: OfferComparisonSchema,
|
|
171
172
|
annotations: {
|
|
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
175
176
|
openWorldHint: false,
|
|
176
177
|
},
|
|
177
178
|
}, async (args) => {
|
|
178
|
-
const wallet = await ctx.cardsFor(args.cards,
|
|
179
|
+
const wallet = await ctx.cardsFor(args.cards, args.memberships);
|
|
179
180
|
if (typeof wallet === 'string')
|
|
180
181
|
return fail(wallet);
|
|
181
|
-
const
|
|
182
|
+
const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
183
|
+
const base = args.memberships
|
|
184
|
+
? { ...found, memberships: args.memberships }
|
|
185
|
+
: found;
|
|
182
186
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
183
187
|
kind: args.kind ?? 'any',
|
|
184
188
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
391
395
|
});
|
|
392
396
|
server.registerTool('search_cards', {
|
|
393
397
|
title: 'Search cards',
|
|
394
|
-
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet,
|
|
398
|
+
description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
|
|
395
399
|
inputSchema: z.object({
|
|
396
400
|
query: z
|
|
397
401
|
.string()
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -63,12 +63,11 @@ function changes(catalog, args) {
|
|
|
63
63
|
out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
|
|
64
64
|
return out;
|
|
65
65
|
}
|
|
66
|
-
// The tools that read or change a saved wallet.
|
|
67
66
|
export function registerWalletTools(server, deps, ctx, inputs) {
|
|
68
67
|
const { catalog, store } = deps;
|
|
69
68
|
server.registerTool('list_wallet', {
|
|
70
69
|
title: 'List wallet',
|
|
71
|
-
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way,
|
|
70
|
+
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
72
71
|
outputSchema: WalletSummarySchema,
|
|
73
72
|
annotations: {
|
|
74
73
|
title: 'List wallet',
|
|
@@ -102,7 +101,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
102
101
|
});
|
|
103
102
|
server.registerTool('record_purchase', {
|
|
104
103
|
title: 'Record a purchase',
|
|
105
|
-
description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend
|
|
104
|
+
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}`,
|
|
106
105
|
inputSchema: z.object({
|
|
107
106
|
cardId: z
|
|
108
107
|
.string()
|
|
@@ -142,6 +141,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
142
141
|
if (!result.ok)
|
|
143
142
|
return fail(`Couldn't record it: ${result.error}`);
|
|
144
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;
|
|
145
149
|
const output = booking || bonus || milestones.length > 0 || credit
|
|
146
150
|
? {
|
|
147
151
|
recorded: true,
|
|
@@ -149,7 +153,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
149
153
|
bonus,
|
|
150
154
|
milestones,
|
|
151
155
|
credit,
|
|
152
|
-
message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
|
|
156
|
+
message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
|
|
153
157
|
}
|
|
154
158
|
: {
|
|
155
159
|
recorded: false,
|
|
@@ -157,7 +161,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
157
161
|
bonus: null,
|
|
158
162
|
milestones: [],
|
|
159
163
|
credit: null,
|
|
160
|
-
message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend
|
|
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.`,
|
|
161
165
|
};
|
|
162
166
|
return {
|
|
163
167
|
content: [
|
|
@@ -191,7 +195,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
191
195
|
});
|
|
192
196
|
server.registerTool('update_card', {
|
|
193
197
|
title: 'Update a card',
|
|
194
|
-
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend
|
|
198
|
+
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend bonuses (milestoneSpent), statement credits enrolled in (enrolled) 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}`,
|
|
195
199
|
inputSchema: CardChange,
|
|
196
200
|
outputSchema: WalletChangeSchema,
|
|
197
201
|
annotations: {
|
package/dist/schema/card.js
CHANGED
|
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
|
|
|
157
157
|
})),
|
|
158
158
|
])),
|
|
159
159
|
});
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// only those rules' rates, inside their caps; past a cap they earn what they
|
|
164
|
-
// always do. Without, it changes every rate on the card, past caps included.
|
|
160
|
+
// Counted from the opening day. With `rules`, it changes only those rules'
|
|
161
|
+
// rates, inside their caps; past a cap they earn what they always do.
|
|
162
|
+
// Without, it changes every rate on the card, past caps included.
|
|
165
163
|
export const IntroRateSchema = z
|
|
166
164
|
.strictObject({
|
|
167
165
|
id: KebabId,
|
|
@@ -172,11 +170,9 @@ export const IntroRateSchema = z
|
|
|
172
170
|
note: z.string().optional(),
|
|
173
171
|
})
|
|
174
172
|
.refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
// earns the card's rate on its whole amount, and the credit comes on top.
|
|
179
|
-
// `half_year` runs January to June and July to December.
|
|
173
|
+
// Separate from earn rates: the purchase still earns the card's rate on its
|
|
174
|
+
// whole amount, and the credit comes on top. `half_year` runs January to June
|
|
175
|
+
// and July to December.
|
|
180
176
|
export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
|
|
181
177
|
export const CreditSchema = z.strictObject({
|
|
182
178
|
id: KebabId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "earnrate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"credit cards",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
|
|
55
55
|
"site:build": "vite build site && node scripts/build-pages.ts",
|
|
56
56
|
"site:dev": "vite site",
|
|
57
|
+
"site:preview": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.preview.jsonc",
|
|
57
58
|
"site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
|
|
58
59
|
"test": "vitest run",
|
|
59
60
|
"test:watch": "vitest",
|