earnrate 0.2.4 → 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 +5 -5
- package/data/CHANGELOG.md +39 -0
- package/data/VERSION +1 -1
- 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-united-club.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
- package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
- 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 +25 -0
- package/dist/cli/cheatsheet.js +2 -2
- package/dist/cli/explore.js +11 -10
- package/dist/cli/import.js +9 -9
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +17 -3
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +18 -2
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +75 -0
- package/dist/engine/cheat-sheet.js +14 -17
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +10 -21
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +16 -4
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +10 -23
- package/dist/engine/setup.js +14 -16
- package/dist/engine/simulate.js +5 -5
- package/dist/engine/text.js +36 -22
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +17 -0
- package/dist/engine/wallet-summary.js +24 -6
- package/dist/format/card-text.js +15 -19
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +14 -15
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +5 -5
- package/dist/format/wallet.js +4 -1
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +15 -6
- package/dist/mcp/schemas.js +8 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +28 -12
- package/dist/mcp/tools/wallet.js +58 -5
- package/dist/schema/card.js +4 -11
- 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/package.json +1 -1
package/dist/format/explore.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { membershipNote } from "../engine/memberships.js";
|
|
2
|
-
import {
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
.map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
|
|
1
|
+
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
+
import { choiceName, feeText, money, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
|
+
const membershipFees = (memberships) => memberships
|
|
4
|
+
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
6
5
|
.join('');
|
|
7
6
|
const TOP_BEST = 10;
|
|
8
|
-
export function formatExploration(r, all) {
|
|
7
|
+
export function formatExploration(r, catalog, all) {
|
|
9
8
|
const m = r.merchant;
|
|
10
9
|
const title = m.source === 'category'
|
|
11
10
|
? m.resolvedName.toLowerCase()
|
|
@@ -24,7 +23,7 @@ export function formatExploration(r, all) {
|
|
|
24
23
|
lines.push(` ${c}`);
|
|
25
24
|
const upgrade = e.withMembership[0];
|
|
26
25
|
if (upgrade)
|
|
27
|
-
lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
26
|
+
lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
28
27
|
for (const w of e.warnings)
|
|
29
28
|
lines.push(` ! ${w}`);
|
|
30
29
|
});
|
|
@@ -36,8 +35,8 @@ export function formatExploration(r, all) {
|
|
|
36
35
|
return `${lines.join('\n')}\n`;
|
|
37
36
|
}
|
|
38
37
|
function picksText(picks) {
|
|
39
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${choice}
|
|
40
|
-
return parts.length === 0 ? '' : ` (
|
|
38
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
39
|
+
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
41
40
|
}
|
|
42
41
|
function cardNamer(catalog) {
|
|
43
42
|
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
@@ -60,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
60
59
|
return 'No cards match.\n';
|
|
61
60
|
const top = all ? 20 : TOP_SIMULATED;
|
|
62
61
|
const lines = [
|
|
63
|
-
`A year of this spending: ${
|
|
62
|
+
`A year of this spending: ${wholeDollars(first.spend)}.`,
|
|
64
63
|
'',
|
|
65
64
|
'Best single cards (rewards minus annual fee, per year):',
|
|
66
65
|
];
|
|
@@ -68,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
68
67
|
const card = s.cards[0];
|
|
69
68
|
if (!card)
|
|
70
69
|
return;
|
|
71
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${
|
|
70
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
|
|
72
71
|
});
|
|
73
72
|
const pairs = r.pairs.slice(0, top);
|
|
74
73
|
if (pairs.length > 0) {
|
|
75
74
|
lines.push('', 'Best two-card pairs:');
|
|
76
75
|
pairs.forEach((s, i) => {
|
|
77
|
-
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${
|
|
76
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
|
|
78
77
|
if (s.alternatives.length > 0)
|
|
79
78
|
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
80
79
|
for (const split of splitText(s, name))
|
|
@@ -96,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
96
95
|
export function formatAdditions(r, catalog, all) {
|
|
97
96
|
const name = cardNamer(catalog);
|
|
98
97
|
const lines = [
|
|
99
|
-
`Your wallet earns about ${
|
|
98
|
+
`Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
|
|
100
99
|
];
|
|
101
100
|
for (const split of splitText(r.current, name))
|
|
102
101
|
lines.push(` ${split}`);
|
|
@@ -107,7 +106,7 @@ export function formatAdditions(r, catalog, all) {
|
|
|
107
106
|
else {
|
|
108
107
|
lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
|
|
109
108
|
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
110
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${
|
|
109
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
|
|
111
110
|
});
|
|
112
111
|
}
|
|
113
112
|
const notes = new Set([
|
|
@@ -118,6 +117,6 @@ export function formatAdditions(r, catalog, all) {
|
|
|
118
117
|
]);
|
|
119
118
|
if (notes.size > 0)
|
|
120
119
|
lines.push('', ...notes);
|
|
121
|
-
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
120
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
122
121
|
return `${lines.join('\n')}\n`;
|
|
123
122
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { offerTerms } from "../engine/offers.js";
|
|
2
|
+
import { feeText, money, shortDate, wholeDollars } from "../engine/text.js";
|
|
2
3
|
const TOP = 10;
|
|
3
|
-
function reward(o, currencyName) {
|
|
4
|
-
const amount = o.dollars !== null
|
|
5
|
-
? money(o.dollars)
|
|
6
|
-
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
-
return o.varies ? `Up to ${amount}` : amount;
|
|
8
|
-
}
|
|
9
4
|
export function formatOffers(c, currencyName, all) {
|
|
10
5
|
if (c.offers.length === 0) {
|
|
11
6
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -15,19 +10,19 @@ export function formatOffers(c, currencyName, all) {
|
|
|
15
10
|
const lines = [];
|
|
16
11
|
shown.forEach((o, i) => {
|
|
17
12
|
const weeks = (o.months * 52) / 12;
|
|
18
|
-
const pace = o.spend > 0 ? ` (about ${
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
: 'with no spending required';
|
|
22
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
23
|
-
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
13
|
+
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
+
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
24
16
|
if (o.ineligible)
|
|
25
17
|
lines.push(` ${o.ineligible}`);
|
|
26
18
|
if (o.note)
|
|
27
19
|
lines.push(` ${o.note}`);
|
|
28
20
|
});
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
const ruledOut = c.offers.filter((o) => o.ineligible !== null && !shown.includes(o));
|
|
22
|
+
if (ruledOut.length > 0)
|
|
23
|
+
lines.push(` ${String(ruledOut.length)} more left out as likely not eligible with a card you hold: ${ruledOut.map((o) => o.cardName).join(', ')}.`);
|
|
24
|
+
if (c.offers.length > shown.length + ruledOut.length)
|
|
25
|
+
lines.push(` + ${String(c.offers.length - shown.length - ruledOut.length)} more (--all lists them)`);
|
|
31
26
|
if (c.withoutOffer.length > 0)
|
|
32
27
|
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
33
28
|
lines.push('', ...c.notes);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { money, pct } from "../engine/text.js";
|
|
1
|
+
import { dollarsAndCents, money, pct } from "../engine/text.js";
|
|
2
|
+
import { noCardText } from "../engine/recommend.js";
|
|
2
3
|
export const plain = (_format, text) => text;
|
|
3
4
|
const TOP = 3;
|
|
4
|
-
const cents = (n) => `$${n.toFixed(2)}`;
|
|
5
5
|
const label = (c) => c.checkoutLabel ?? c.cardName;
|
|
6
6
|
function header(rec, purchase) {
|
|
7
7
|
const m = rec.merchant;
|
|
@@ -30,7 +30,7 @@ function cardLines(cards, style, bonusPick) {
|
|
|
30
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
31
31
|
const lines = [];
|
|
32
32
|
cards.forEach((c) => {
|
|
33
|
-
const earned = c.earned === null ? '' : ` ${
|
|
33
|
+
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
|
|
34
34
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
35
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
36
36
|
for (const r of c.reasoning)
|
|
@@ -50,9 +50,9 @@ function excludedLines(excluded) {
|
|
|
50
50
|
export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
|
|
51
51
|
const lines = [header(rec, purchase), ''];
|
|
52
52
|
const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
|
|
53
|
-
const noCard =
|
|
53
|
+
const noCard = noCardText(rec.merchant.resolvedName);
|
|
54
54
|
if (rec.decision && top) {
|
|
55
|
-
const earned = top.earned === null ? '' : `, about ${
|
|
55
|
+
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
56
56
|
const confidence = rec.decision.confidence;
|
|
57
57
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
58
|
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${confidence[0]?.toUpperCase() ?? ''}${confidence.slice(1)} confidence.`);
|
package/dist/format/wallet.js
CHANGED
|
@@ -42,7 +42,10 @@ export function formatWalletSummary(summary) {
|
|
|
42
42
|
const memberships = summary.memberships.length > 0
|
|
43
43
|
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
44
44
|
: 'Memberships: none saved.';
|
|
45
|
-
|
|
45
|
+
const values = summary.pointValues.length > 0
|
|
46
|
+
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
|
47
|
+
: "Point values: earnrate's defaults.";
|
|
48
|
+
return [...cards, memberships, values].join('\n');
|
|
46
49
|
}
|
|
47
50
|
export function formatBooking(amount, cardId, cap, bonus) {
|
|
48
51
|
const messages = [];
|
package/dist/mcp/context.js
CHANGED
|
@@ -54,15 +54,34 @@ export function makeContext(deps) {
|
|
|
54
54
|
}
|
|
55
55
|
// Cards named in the conversation come first, then the saved wallet; with
|
|
56
56
|
// neither (null), answers cover every card earnrate knows.
|
|
57
|
+
// The point values and memberships the user saved apply to every answer,
|
|
58
|
+
// not just ones from their saved cards. A wallet that can't be read has
|
|
59
|
+
// none.
|
|
60
|
+
async function saved() {
|
|
61
|
+
try {
|
|
62
|
+
const wallet = await deps.store.load();
|
|
63
|
+
return {
|
|
64
|
+
valuations: wallet?.valuations ?? {},
|
|
65
|
+
memberships: wallet?.memberships ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { valuations: {}, memberships: [] };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
57
72
|
async function cardsFor(cards, memberships) {
|
|
58
|
-
if (cards && cards.length > 0)
|
|
59
|
-
|
|
73
|
+
if (cards && cards.length > 0) {
|
|
74
|
+
const named = walletFromCards(catalog, {
|
|
60
75
|
cards: cards.map(({ cardId, ...rest }) => ({
|
|
61
76
|
id: cardId,
|
|
62
77
|
...defined(rest),
|
|
63
78
|
})),
|
|
64
|
-
|
|
79
|
+
memberships: memberships ?? (await saved()).memberships,
|
|
65
80
|
}, deps.today());
|
|
81
|
+
return typeof named === 'string'
|
|
82
|
+
? named
|
|
83
|
+
: { ...named, valuations: (await saved()).valuations };
|
|
84
|
+
}
|
|
66
85
|
const read = await readableWallet();
|
|
67
86
|
if (typeof read === 'string')
|
|
68
87
|
return read;
|
|
@@ -92,5 +111,13 @@ export function makeContext(deps) {
|
|
|
92
111
|
};
|
|
93
112
|
}
|
|
94
113
|
const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
95
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
wallet,
|
|
116
|
+
readableWallet,
|
|
117
|
+
resolve,
|
|
118
|
+
cardsFor,
|
|
119
|
+
saved,
|
|
120
|
+
changed,
|
|
121
|
+
cardName,
|
|
122
|
+
};
|
|
96
123
|
}
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
3
|
-
import { VALUATION_PROFILES } from "../engine/profiles.js";
|
|
3
|
+
import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
4
|
import { knownMemberships } from "../engine/setup.js";
|
|
5
5
|
import { IsoDate } from "../schema/common.js";
|
|
6
6
|
export const Merchant = z
|
|
@@ -112,6 +112,8 @@ export function makeInputs(catalog) {
|
|
|
112
112
|
.enum(categoryIds)
|
|
113
113
|
.optional()
|
|
114
114
|
.describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
|
|
115
|
+
// record_purchase can't skip an unknown merchant's caps, so it asks.
|
|
116
|
+
const RecordCategoryInput = CategoryInput.describe(`For a merchant earnrate doesn't know, its spending category, e.g. dining for a restaurant: without one the purchase isn't recorded, since its caps can't be told. If nobody knows, other records it toward a welcome bonus without touching any cap. Ignored when earnrate knows the merchant. Categories: ${categoryList}.`);
|
|
115
117
|
const CatalogCategory = z
|
|
116
118
|
.enum(categoryIds)
|
|
117
119
|
.describe(`A spending category: ${categoryList}.`);
|
|
@@ -128,11 +130,12 @@ export function makeInputs(catalog) {
|
|
|
128
130
|
valuation: z
|
|
129
131
|
.enum(['flat', 'points-heavy'])
|
|
130
132
|
.optional()
|
|
131
|
-
.describe('
|
|
133
|
+
.describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
|
|
132
134
|
};
|
|
133
135
|
const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
|
|
134
136
|
return {
|
|
135
137
|
CategoryInput,
|
|
138
|
+
RecordCategoryInput,
|
|
136
139
|
CatalogCategory,
|
|
137
140
|
NamedCards,
|
|
138
141
|
NamedMemberships,
|
|
@@ -141,8 +144,12 @@ export function makeInputs(catalog) {
|
|
|
141
144
|
AllMemberships,
|
|
142
145
|
};
|
|
143
146
|
}
|
|
144
|
-
// The catalog filters as exploreCards and simulate take them.
|
|
145
|
-
|
|
147
|
+
// The catalog filters as exploreCards and simulate take them. Memberships or
|
|
148
|
+
// a valuation profile asked for win over the user's saved ones.
|
|
149
|
+
export const settings = (args, saved = {
|
|
150
|
+
valuations: {},
|
|
151
|
+
memberships: [],
|
|
152
|
+
}) => ({
|
|
146
153
|
filters: {
|
|
147
154
|
...ALL_PERSONAL_CARDS,
|
|
148
155
|
kind: args.kind ?? 'any',
|
|
@@ -150,6 +157,8 @@ export const settings = (args) => ({
|
|
|
150
157
|
business: args.business ?? false,
|
|
151
158
|
issuer: args.issuer ?? null,
|
|
152
159
|
},
|
|
153
|
-
memberships: args.memberships ??
|
|
154
|
-
valuations: args.valuation
|
|
160
|
+
memberships: args.memberships ?? saved.memberships,
|
|
161
|
+
valuations: args.valuation
|
|
162
|
+
? VALUATION_PROFILES[args.valuation]
|
|
163
|
+
: saved.valuations,
|
|
155
164
|
});
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -78,6 +78,13 @@ const RecommendationSchema = z.object({
|
|
|
78
78
|
});
|
|
79
79
|
export const WalletSummarySchema = z.object({
|
|
80
80
|
memberships: z.array(z.string()),
|
|
81
|
+
pointValues: z.array(z.object({
|
|
82
|
+
currency: z.string(),
|
|
83
|
+
name: z.string(),
|
|
84
|
+
centsPerUnit: z.number(),
|
|
85
|
+
redemption: z.string().nullable(),
|
|
86
|
+
})),
|
|
87
|
+
started: z.string().nullable(),
|
|
81
88
|
cards: z.array(z.object({
|
|
82
89
|
cardId: z.string(),
|
|
83
90
|
cardName: z.string(),
|
|
@@ -99,6 +106,7 @@ export const WalletSummarySchema = z.object({
|
|
|
99
106
|
})
|
|
100
107
|
.nullable(),
|
|
101
108
|
currency: z.string(),
|
|
109
|
+
currencyName: z.string(),
|
|
102
110
|
centsPerUnit: z.number(),
|
|
103
111
|
lastVerified: z.string(),
|
|
104
112
|
verifiedBy: z.string(),
|
package/dist/mcp/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { WalletChangeSchema } from "../schemas.js";
|
|
|
7
7
|
export function registerAccountTools(server, catalog, store, ctx) {
|
|
8
8
|
server.registerTool('export_wallet', {
|
|
9
9
|
title: 'Export the wallet',
|
|
10
|
-
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
|
|
10
|
+
description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and bonus progress. Never card numbers.',
|
|
11
11
|
inputSchema: z.object({}),
|
|
12
12
|
outputSchema: z.object({ yaml: z.string() }),
|
|
13
13
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
@@ -24,6 +24,7 @@ const SOURCES = {
|
|
|
24
24
|
category: 'a kind of purchase, not a store',
|
|
25
25
|
none: 'not recognized',
|
|
26
26
|
};
|
|
27
|
+
const EVERY_CARD_NOTE = "Picked from every card earnrate knows, as asked, not just the user's saved cards. The user's saved memberships and point values still apply.";
|
|
27
28
|
const TOP = 10;
|
|
28
29
|
// The tools that answer without a saved wallet: from every card, the cards
|
|
29
30
|
// named in the conversation, or a saved wallet if there is one.
|
|
@@ -73,14 +74,14 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
73
74
|
amount: purchase.amount,
|
|
74
75
|
channel: purchase.channel,
|
|
75
76
|
date: purchase.date,
|
|
76
|
-
}, settings({ memberships }), resolved.merchant);
|
|
77
|
+
}, settings({ memberships }, await ctx.saved()), resolved.merchant);
|
|
77
78
|
const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
|
|
78
79
|
const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
|
|
79
80
|
return {
|
|
80
81
|
content: [
|
|
81
82
|
{
|
|
82
83
|
type: 'text',
|
|
83
|
-
text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
|
|
84
|
+
text: `${retry}${formatExploration(allCards, catalog, true)}\n${ALL_CARDS_NOTE}`,
|
|
84
85
|
},
|
|
85
86
|
],
|
|
86
87
|
structuredContent: {
|
|
@@ -155,7 +156,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
155
156
|
const wallet = await ctx.cardsFor(args.cards, undefined);
|
|
156
157
|
if (typeof wallet === 'string')
|
|
157
158
|
return fail(wallet);
|
|
158
|
-
const base = wallet ?? EMPTY_WALLET;
|
|
159
|
+
const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
159
160
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
160
161
|
kind: args.kind ?? 'any',
|
|
161
162
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
@@ -164,7 +165,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
164
165
|
}, deps.today());
|
|
165
166
|
const trimmed = {
|
|
166
167
|
...comparison,
|
|
167
|
-
|
|
168
|
+
// Offers ruled out by a card the user holds sort last; keep them in
|
|
169
|
+
// view so the reason shows.
|
|
170
|
+
offers: comparison.offers.filter((o, i) => i < TOP || o.ineligible !== null),
|
|
168
171
|
};
|
|
169
172
|
const more = comparison.offers.length - trimmed.offers.length;
|
|
170
173
|
return {
|
|
@@ -173,7 +176,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
173
176
|
type: 'text',
|
|
174
177
|
text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
|
|
175
178
|
(more > 0
|
|
176
|
-
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
|
|
179
|
+
? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers${trimmed.offers.length > TOP ? `, plus ${String(trimmed.offers.length - TOP)} likely not eligible` : ''}. Narrow them with kind, noAnnualFee or business.\n`
|
|
177
180
|
: ''),
|
|
178
181
|
},
|
|
179
182
|
],
|
|
@@ -196,7 +199,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
196
199
|
}),
|
|
197
200
|
outputSchema: ExplorationSchema,
|
|
198
201
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
199
|
-
}, (args) => {
|
|
202
|
+
}, async (args) => {
|
|
200
203
|
if (args.merchant === undefined && args.category === undefined)
|
|
201
204
|
return fail('Give a merchant or a category.');
|
|
202
205
|
const result = exploreCards(catalog, {
|
|
@@ -205,10 +208,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
205
208
|
amount: args.amount ?? null,
|
|
206
209
|
channel: args.channel ?? null,
|
|
207
210
|
date: deps.today(),
|
|
208
|
-
}, settings(args));
|
|
211
|
+
}, settings(args, await ctx.saved()));
|
|
209
212
|
const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
|
|
210
213
|
return {
|
|
211
|
-
content: [
|
|
214
|
+
content: [
|
|
215
|
+
{ type: 'text', text: formatExploration(trimmed, catalog, true) },
|
|
216
|
+
],
|
|
212
217
|
structuredContent: trimmed,
|
|
213
218
|
};
|
|
214
219
|
});
|
|
@@ -248,7 +253,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
248
253
|
if (unknown.length > 0)
|
|
249
254
|
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
250
255
|
const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
|
|
251
|
-
const options = {
|
|
256
|
+
const options = {
|
|
257
|
+
...settings(args, await ctx.saved()),
|
|
258
|
+
start: deps.today(),
|
|
259
|
+
};
|
|
252
260
|
const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
|
|
253
261
|
args.compareToWallet === true;
|
|
254
262
|
const wallet = wantsTheirs
|
|
@@ -296,6 +304,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
296
304
|
inputSchema: z.object({
|
|
297
305
|
date: DateInput,
|
|
298
306
|
cards: NamedCards,
|
|
307
|
+
everyCard: z
|
|
308
|
+
.boolean()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe('true to pick from every card earnrate knows even when the user has a saved wallet ("which cards should I get?"); the filters then apply.'),
|
|
299
311
|
memberships: NamedMemberships,
|
|
300
312
|
kind: CatalogFilters.kind,
|
|
301
313
|
noAnnualFee: CatalogFilters.noAnnualFee,
|
|
@@ -306,14 +318,18 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
306
318
|
outputSchema: CheatSheetSchema,
|
|
307
319
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
308
320
|
}, async (args) => {
|
|
309
|
-
const wallet =
|
|
321
|
+
const wallet = args.everyCard === true
|
|
322
|
+
? null
|
|
323
|
+
: await ctx.cardsFor(args.cards, args.memberships);
|
|
310
324
|
if (typeof wallet === 'string')
|
|
311
325
|
return fail(wallet);
|
|
312
326
|
const date = args.date ?? deps.today();
|
|
313
327
|
const sheet = wallet === null
|
|
314
328
|
? {
|
|
315
|
-
...catalogCheatSheet(catalog, date, settings(args)),
|
|
316
|
-
notes: [
|
|
329
|
+
...catalogCheatSheet(catalog, date, settings(args, await ctx.saved())),
|
|
330
|
+
notes: [
|
|
331
|
+
args.everyCard === true ? EVERY_CARD_NOTE : ALL_CARDS_NOTE,
|
|
332
|
+
],
|
|
317
333
|
}
|
|
318
334
|
: cheatSheet(catalog, args.valuation
|
|
319
335
|
? withValuationProfile(wallet, args.valuation)
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
|
|
3
|
-
import { choiceName, money, words } from "../../engine/text.js";
|
|
3
|
+
import { choiceName, currencyLabel, money, shortDate, words, } from "../../engine/text.js";
|
|
4
|
+
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
4
5
|
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
5
6
|
import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
|
|
6
7
|
import { fail } from "../context.js";
|
|
7
8
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
8
9
|
import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
|
|
9
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
|
+
}
|
|
10
25
|
// What update_card changed, so the reply confirms the values it saved.
|
|
11
26
|
function changes(args) {
|
|
12
27
|
const out = [];
|
|
@@ -35,17 +50,25 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
35
50
|
const { catalog, store } = deps;
|
|
36
51
|
server.registerTool('list_wallet', {
|
|
37
52
|
title: 'List wallet',
|
|
38
|
-
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.",
|
|
39
54
|
outputSchema: WalletSummarySchema,
|
|
40
55
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
41
56
|
}, async () => {
|
|
42
57
|
const read = await ctx.readableWallet();
|
|
43
58
|
if (typeof read === 'string')
|
|
44
59
|
return fail(read);
|
|
45
|
-
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`;
|
|
46
66
|
return {
|
|
47
67
|
content: [
|
|
48
|
-
{
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: `${when}${read.start ?? formatWalletSummary(summary)}`,
|
|
71
|
+
},
|
|
49
72
|
],
|
|
50
73
|
structuredContent: summary,
|
|
51
74
|
};
|
|
@@ -62,7 +85,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
62
85
|
amount: z.number().positive().describe('Amount spent in US dollars.'),
|
|
63
86
|
channel: ChannelInput,
|
|
64
87
|
date: DateInput,
|
|
65
|
-
category: inputs.
|
|
88
|
+
category: inputs.RecordCategoryInput,
|
|
66
89
|
}),
|
|
67
90
|
outputSchema: RecordResultSchema,
|
|
68
91
|
annotations: {
|
|
@@ -167,4 +190,34 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
167
190
|
? `Memberships set to ${memberships.join(', ')}.`
|
|
168
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 });
|
|
169
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
|
+
});
|
|
170
223
|
}
|
package/dist/schema/card.js
CHANGED
|
@@ -146,17 +146,10 @@ export const CardSchema = z
|
|
|
146
146
|
// When the first year differs, usually a waived fee.
|
|
147
147
|
first_year_fee: z.number().nonnegative().optional(),
|
|
148
148
|
welcome_offer: WelcomeOfferSchema.optional(),
|
|
149
|
-
// A paid membership the card can't be had
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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(),
|
|
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(),
|
|
160
153
|
pools_into: z
|
|
161
154
|
.strictObject({
|
|
162
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);
|