earnrate 0.2.3 → 0.2.5
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 +15 -14
- package/data/CHANGELOG.md +74 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- 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/chase-united-club.yaml +1 -1
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +33 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +3 -3
- package/dist/cli/explore.js +12 -11
- package/dist/cli/import.js +13 -12
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +18 -4
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +26 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +83 -2
- package/dist/engine/cheat-sheet.js +53 -14
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +18 -20
- package/dist/engine/memberships.js +71 -0
- package/dist/engine/offers.js +37 -5
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +78 -44
- package/dist/engine/setup.js +22 -5
- package/dist/engine/simulate.js +36 -14
- package/dist/engine/text.js +67 -2
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +42 -4
- package/dist/engine/wallet-summary.js +83 -37
- package/dist/format/card-text.js +19 -29
- package/dist/format/cards.js +8 -9
- package/dist/format/explore.js +22 -14
- package/dist/format/offers.js +12 -21
- package/dist/format/recommendation.js +16 -10
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +16 -20
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +17 -8
- package/dist/mcp/schemas.js +21 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +28 -10
- package/dist/mcp/tools/answers.js +51 -18
- package/dist/mcp/tools/wallet.js +106 -11
- package/dist/schema/card.js +7 -0
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -1,33 +1,81 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
3
|
+
import { choiceName, currencyLabel, money, shortDate, words, } from "../../engine/text.js";
|
|
4
|
+
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
2
5
|
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
3
6
|
import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
|
|
4
7
|
import { fail } from "../context.js";
|
|
5
8
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
6
9
|
import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
|
|
7
10
|
const WRITE = { readOnlyHint: false, openWorldHint: false };
|
|
11
|
+
// "Saved wallet started 4 minutes ago": relative, since the server doesn't
|
|
12
|
+
// know the user's time zone, and precise enough to tell apart two wallets
|
|
13
|
+
// started the same day.
|
|
14
|
+
function startedText(started, now) {
|
|
15
|
+
const minutes = Math.floor((now - Date.parse(started)) / 60_000);
|
|
16
|
+
const ago = minutes < 1
|
|
17
|
+
? 'just now'
|
|
18
|
+
: minutes < 60
|
|
19
|
+
? `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`
|
|
20
|
+
: minutes < 48 * 60
|
|
21
|
+
? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
|
|
22
|
+
: `on ${shortDate(started.slice(0, 10))}`;
|
|
23
|
+
return `Saved wallet started ${ago}.`;
|
|
24
|
+
}
|
|
25
|
+
// What update_card changed, so the reply confirms the values it saved.
|
|
26
|
+
function changes(args) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const [choice, options] of Object.entries(args.picks ?? {}))
|
|
29
|
+
out.push(`${choiceName(choice)} picks: ${options.map(words).join(', ')}`);
|
|
30
|
+
if (args.checkoutLabel === null)
|
|
31
|
+
out.push('checkout name cleared');
|
|
32
|
+
else if (args.checkoutLabel !== undefined)
|
|
33
|
+
out.push(`checkout name "${args.checkoutLabel}"`);
|
|
34
|
+
if (args.anniversary !== undefined)
|
|
35
|
+
out.push(`anniversary ${args.anniversary}`);
|
|
36
|
+
if (args.activated !== undefined)
|
|
37
|
+
out.push(args.activated.length === 0
|
|
38
|
+
? 'nothing activated'
|
|
39
|
+
: `activated: ${args.activated.join(', ')}`);
|
|
40
|
+
for (const [key, spent] of Object.entries(args.capSpent ?? {}))
|
|
41
|
+
out.push(`${money(spent)} already spent on ${key} this period`);
|
|
42
|
+
if (args.bonus === null)
|
|
43
|
+
out.push('welcome bonus no longer tracked');
|
|
44
|
+
else if (args.bonus !== undefined)
|
|
45
|
+
out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
8
48
|
// The tools that read or change a saved wallet.
|
|
9
49
|
export function registerWalletTools(server, deps, ctx, inputs) {
|
|
10
50
|
const { catalog, store } = deps;
|
|
11
51
|
server.registerTool('list_wallet', {
|
|
12
52
|
title: 'List wallet',
|
|
13
|
-
description: "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, which stores a store card works at,
|
|
53
|
+
description: "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, 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.",
|
|
14
54
|
outputSchema: WalletSummarySchema,
|
|
15
55
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
16
56
|
}, async () => {
|
|
17
57
|
const read = await ctx.readableWallet();
|
|
18
58
|
if (typeof read === 'string')
|
|
19
59
|
return fail(read);
|
|
20
|
-
const
|
|
60
|
+
const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
|
|
61
|
+
const summary = {
|
|
62
|
+
...walletSummary(catalog, read.wallet, deps.today()),
|
|
63
|
+
started,
|
|
64
|
+
};
|
|
65
|
+
const when = started === null ? '' : `${startedText(started, Date.now())}\n`;
|
|
21
66
|
return {
|
|
22
67
|
content: [
|
|
23
|
-
{
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: `${when}${read.start ?? formatWalletSummary(summary)}`,
|
|
71
|
+
},
|
|
24
72
|
],
|
|
25
73
|
structuredContent: summary,
|
|
26
74
|
};
|
|
27
75
|
});
|
|
28
76
|
server.registerTool('record_purchase', {
|
|
29
77
|
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.",
|
|
78
|
+
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
79
|
inputSchema: z.object({
|
|
32
80
|
cardId: z
|
|
33
81
|
.string()
|
|
@@ -37,7 +85,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
37
85
|
amount: z.number().positive().describe('Amount spent in US dollars.'),
|
|
38
86
|
channel: ChannelInput,
|
|
39
87
|
date: DateInput,
|
|
40
|
-
category: inputs.
|
|
88
|
+
category: inputs.RecordCategoryInput,
|
|
41
89
|
}),
|
|
42
90
|
outputSchema: RecordResultSchema,
|
|
43
91
|
annotations: {
|
|
@@ -60,6 +108,8 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
60
108
|
date: date ?? deps.today(),
|
|
61
109
|
};
|
|
62
110
|
const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
|
|
111
|
+
if (resolved.merchant.source === 'none')
|
|
112
|
+
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
113
|
const result = await store.record(purchase, resolved.merchant);
|
|
64
114
|
if (!result.ok)
|
|
65
115
|
return fail(`Couldn't record it: ${result.error}`);
|
|
@@ -104,11 +154,18 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
104
154
|
});
|
|
105
155
|
server.registerTool('update_card', {
|
|
106
156
|
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
|
|
157
|
+
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
158
|
inputSchema: CardChange,
|
|
109
159
|
outputSchema: WalletChangeSchema,
|
|
110
160
|
annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
|
|
111
|
-
}, async (args) =>
|
|
161
|
+
}, async (args) => {
|
|
162
|
+
const what = changes(args);
|
|
163
|
+
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
164
|
+
const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
|
|
165
|
+
? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
|
|
166
|
+
: '';
|
|
167
|
+
return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
|
|
168
|
+
});
|
|
112
169
|
server.registerTool('remove_card', {
|
|
113
170
|
title: 'Remove a card',
|
|
114
171
|
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 +175,49 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
118
175
|
}, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
|
|
119
176
|
server.registerTool('set_memberships', {
|
|
120
177
|
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.",
|
|
178
|
+
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
179
|
inputSchema: z.object({ memberships: inputs.AllMemberships }),
|
|
123
180
|
outputSchema: WalletChangeSchema,
|
|
124
181
|
annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
|
|
125
|
-
}, async ({ memberships }) =>
|
|
126
|
-
|
|
127
|
-
|
|
182
|
+
}, async ({ memberships }) => {
|
|
183
|
+
const result = await store.setMemberships(memberships);
|
|
184
|
+
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
185
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
186
|
+
return card ? membershipsFor(card) : [];
|
|
187
|
+
}));
|
|
188
|
+
const unused = memberships.filter((m) => !used.has(m));
|
|
189
|
+
return ctx.changed(result, `${memberships.length > 0
|
|
190
|
+
? `Memberships set to ${memberships.join(', ')}.`
|
|
191
|
+
: '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 });
|
|
192
|
+
});
|
|
193
|
+
server.registerTool('set_point_values', {
|
|
194
|
+
title: 'Set point values',
|
|
195
|
+
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. It needs a saved wallet with a card in it. 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: ${catalog.currencies.map((c) => c.id).join(', ')}.`,
|
|
196
|
+
inputSchema: z.object({
|
|
197
|
+
values: z
|
|
198
|
+
.record(z.string(), z.number().nullable())
|
|
199
|
+
.optional()
|
|
200
|
+
.describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
|
|
201
|
+
profile: z
|
|
202
|
+
.enum(['flat', 'points-heavy'])
|
|
203
|
+
.optional()
|
|
204
|
+
.describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
|
|
205
|
+
}),
|
|
206
|
+
outputSchema: WalletChangeSchema,
|
|
207
|
+
annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
|
|
208
|
+
}, async ({ values, profile }) => {
|
|
209
|
+
const changes = {
|
|
210
|
+
...(profile ? VALUATION_PROFILES[profile] : {}),
|
|
211
|
+
...(values ?? {}),
|
|
212
|
+
};
|
|
213
|
+
if (Object.keys(changes).length === 0)
|
|
214
|
+
return fail('Give values (cents per point by currency) or a profile.');
|
|
215
|
+
const result = await store.setValuations(changes);
|
|
216
|
+
const set = Object.entries(changes).map(([id, cents]) => cents === null
|
|
217
|
+
? `${currencyLabel(catalog, id)} back to the default`
|
|
218
|
+
: `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
|
|
219
|
+
return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
|
|
220
|
+
notes: false,
|
|
221
|
+
});
|
|
222
|
+
});
|
|
128
223
|
}
|
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,10 @@ 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 (data/memberships.yaml) the card can't be had
|
|
150
|
+
// without, Costco's for Costco Anywhere. Its fee counts like an annual
|
|
151
|
+
// fee for anyone not already paying it.
|
|
152
|
+
requires_membership: KebabId.optional(),
|
|
146
153
|
pools_into: z
|
|
147
154
|
.strictObject({
|
|
148
155
|
currency: CurrencyId,
|
package/dist/schema/category.js
CHANGED
|
@@ -9,5 +9,9 @@ export const CategorySchema = z.strictObject({
|
|
|
9
9
|
// Everyday words people search by ("groceries"), besides the name.
|
|
10
10
|
aliases: z.array(z.string().min(1)).default([]),
|
|
11
11
|
note: z.string().optional(),
|
|
12
|
+
// Bought as a service, a category reads "on" (3x on flights); places read
|
|
13
|
+
// "at". Subcategories inherit it.
|
|
14
|
+
preposition: z.literal('on').optional(),
|
|
15
|
+
slug: z.string().optional(), // its search page, when not from its name
|
|
12
16
|
});
|
|
13
17
|
export const CategoriesFileSchema = z.array(CategorySchema).min(1);
|
package/dist/schema/currency.js
CHANGED
|
@@ -9,6 +9,8 @@ export const CurrencySchema = z.strictObject({
|
|
|
9
9
|
// Value per redemption option, for currencies whose worth depends on how
|
|
10
10
|
// they're redeemed. A wallet picks one by key.
|
|
11
11
|
redemptions: z.record(z.string(), z.number().positive()).optional(),
|
|
12
|
+
// How each redemption reads after "redeemed": "into a SoFi account".
|
|
13
|
+
redemption_names: z.record(z.string(), z.string()).optional(),
|
|
12
14
|
sources: z.array(z.url()).default([]),
|
|
13
15
|
note: z.string().optional(),
|
|
14
16
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { KebabId } from "./common.js";
|
|
3
|
+
export const MembershipSchema = z.strictObject({
|
|
4
|
+
id: KebabId,
|
|
5
|
+
name: z.string().min(1), // "Costco membership"
|
|
6
|
+
// The site's checkbox for it, when it's one most people would know.
|
|
7
|
+
ask: z.string().min(1).optional(),
|
|
8
|
+
// Memberships in one group are tiers: a person holds one at a time.
|
|
9
|
+
group: KebabId.optional(),
|
|
10
|
+
// Dollars a year, for a membership a card needs (`requires_membership`).
|
|
11
|
+
annual_fee: z.number().nonnegative().optional(),
|
|
12
|
+
// Merchants only members can buy from: spending there means a member.
|
|
13
|
+
members_shop_at: z.array(KebabId).default([]),
|
|
14
|
+
});
|
|
15
|
+
export const MembershipsFileSchema = z.array(MembershipSchema).min(1);
|
package/dist/schema/merchant.js
CHANGED
|
@@ -27,6 +27,10 @@ export const MerchantSchema = z.strictObject({
|
|
|
27
27
|
// Other ways to buy the same thing, e.g. through a card's travel site, that
|
|
28
28
|
// can earn more. Answers mention them when they would.
|
|
29
29
|
booked_via: z.array(KebabId).default([]),
|
|
30
|
+
// A card issuer's or store's travel or ticketing site (portal: Amex Travel,
|
|
31
|
+
// Costco Travel), or a booking made through one (booking: "Hotel through
|
|
32
|
+
// Amex Travel").
|
|
33
|
+
kind: z.enum(['portal', 'booking']).optional(),
|
|
30
34
|
note: z.string().optional(),
|
|
31
35
|
});
|
|
32
36
|
export const MerchantsFileSchema = z.array(MerchantSchema);
|
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