earnrate 0.2.8 → 0.2.10
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 +31 -5
- package/data/CHANGELOG.md +145 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +4 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -0
- package/data/cards/amex-blue-cash-preferred.yaml +38 -3
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/amex-platinum.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
- package/data/cards/bilt-obsidian.yaml +4 -0
- package/data/cards/capital-one-kohls-card.yaml +2 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
- package/data/cards/capital-one-savor.yaml +4 -0
- package/data/cards/capital-one-venture-x.yaml +4 -0
- package/data/cards/capital-one-venture.yaml +4 -0
- package/data/cards/chase-aeroplan.yaml +5 -0
- package/data/cards/chase-freedom-flex.yaml +4 -0
- package/data/cards/chase-freedom-unlimited.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards.yaml +57 -7
- package/data/cards/chase-ink-business-cash.yaml +4 -0
- package/data/cards/chase-ink-business-preferred.yaml +4 -0
- package/data/cards/chase-prime-visa.yaml +4 -0
- package/data/cards/chase-sapphire-preferred.yaml +4 -0
- package/data/cards/chase-sapphire-reserve.yaml +4 -0
- package/data/cards/chase-southwest-performance-business.yaml +4 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier-business.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-business.yaml +5 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/chase-united-club.yaml +5 -0
- package/data/cards/chase-united-explorer.yaml +5 -0
- package/data/cards/chase-united-gateway.yaml +5 -0
- package/data/cards/chase-united-quest.yaml +30 -2
- package/data/cards/citi-aadvantage-business.yaml +4 -0
- package/data/cards/citi-aadvantage-executive.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +4 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
- package/data/cards/citi-custom-cash.yaml +2 -0
- package/data/cards/citi-double-cash.yaml +4 -0
- package/data/cards/citi-my-best-buy-visa.yaml +4 -0
- package/data/cards/citi-strata-elite.yaml +4 -0
- package/data/cards/citi-strata-premier.yaml +4 -0
- package/data/cards/sofi-unlimited-2.yaml +4 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
- package/data/cards/td-bank-target-circle-card.yaml +2 -0
- package/data/cards/usbank-cash-plus.yaml +5 -0
- package/data/cards/wells-fargo-active-cash.yaml +4 -0
- package/data/cards/wells-fargo-autograph.yaml +4 -0
- package/data/merchants/overrides.yaml +290 -0
- package/data/merchants/seed.yaml +1049 -2
- package/dist/cli/args.js +3 -6
- package/dist/cli/explore.js +10 -0
- package/dist/cli/import.js +18 -4
- package/dist/cli/run.js +3 -1
- package/dist/cli/wallet.js +5 -1
- package/dist/data/file-store.js +2 -2
- package/dist/data/integrity.js +17 -0
- package/dist/data/store.js +1 -1
- package/dist/data/wallet-edit.js +32 -18
- package/dist/engine/cheat-sheet.js +46 -2
- package/dist/engine/checklist.js +19 -8
- package/dist/engine/evaluate.js +2 -0
- package/dist/engine/explore.js +9 -5
- package/dist/engine/milestones.js +91 -0
- package/dist/engine/periods.js +29 -12
- package/dist/engine/recommend.js +68 -14
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +17 -5
- package/dist/engine/setup.js +13 -2
- package/dist/engine/simulate.js +25 -3
- package/dist/engine/text.js +11 -0
- package/dist/engine/wallet-change.js +67 -0
- package/dist/engine/wallet-summary.js +17 -5
- package/dist/format/cards.js +3 -1
- package/dist/format/recommendation.js +8 -2
- package/dist/format/wallet.js +22 -4
- package/dist/import/analyze.js +42 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/schemas.js +50 -2
- package/dist/mcp/server.js +17 -1
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +18 -10
- package/dist/schema/card.js +22 -1
- package/dist/schema/common.js +4 -0
- package/dist/schema/wallet.js +2 -0
- package/package.json +5 -3
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
1
|
+
import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
2
2
|
// "To do now", above the card details; nothing when all's done.
|
|
3
3
|
export function formatChecklist(items) {
|
|
4
4
|
return items.length === 0
|
|
@@ -34,8 +34,11 @@ export function formatWalletSummary(summary) {
|
|
|
34
34
|
if (c.rotationGap !== null)
|
|
35
35
|
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
36
36
|
for (const cap of c.caps)
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
lines.push(cap.remaining === null
|
|
38
|
+
? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
|
|
39
|
+
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
40
|
+
for (const m of c.milestones)
|
|
41
|
+
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
39
42
|
if (c.anniversary !== null)
|
|
40
43
|
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
41
44
|
const b = c.bonus;
|
|
@@ -53,7 +56,7 @@ export function formatWalletSummary(summary) {
|
|
|
53
56
|
: "Point values: earnrate's defaults.";
|
|
54
57
|
return [...cards, memberships, values].join('\n');
|
|
55
58
|
}
|
|
56
|
-
export function formatBooking(amount, cardId, cap, bonus) {
|
|
59
|
+
export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
57
60
|
const messages = [];
|
|
58
61
|
if (cap) {
|
|
59
62
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
@@ -65,5 +68,20 @@ export function formatBooking(amount, cardId, cap, bonus) {
|
|
|
65
68
|
? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
66
69
|
: `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
67
70
|
}
|
|
71
|
+
// Every milestone counts the same purchases, so one line covers them.
|
|
72
|
+
const first = milestones[0];
|
|
73
|
+
if (first) {
|
|
74
|
+
const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
|
|
75
|
+
const next = milestones.find((m) => m.after < m.spend);
|
|
76
|
+
messages.push([
|
|
77
|
+
`Spend on ${cardId} this year: ${money(first.after)}.`,
|
|
78
|
+
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
79
|
+
...(next
|
|
80
|
+
? [
|
|
81
|
+
`${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
|
|
82
|
+
]
|
|
83
|
+
: []),
|
|
84
|
+
].join(' '));
|
|
85
|
+
}
|
|
68
86
|
return messages.join(' ');
|
|
69
87
|
}
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
2
|
import { HALF_CENT } from "../engine/compare.js";
|
|
3
3
|
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
4
|
+
import { milestoneProgress } from "../engine/milestones.js";
|
|
4
5
|
import { periodKey } from "../engine/periods.js";
|
|
5
6
|
import { recommend } from "../engine/recommend.js";
|
|
6
7
|
import { applyBooking, capBooking } from "../engine/record.js";
|
|
@@ -41,8 +42,14 @@ function capPeriodKey(catalog, wallet, booking, date) {
|
|
|
41
42
|
// up partway through the year is accounted for on both sides.
|
|
42
43
|
export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorded = () => false) {
|
|
43
44
|
const sorted = [...entries].sort((a, b) => a.line.date.localeCompare(b.line.date));
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// The earned-versus-best replay leaves spend milestones out: they pay once
|
|
46
|
+
// on the card's total, whichever card is best for each purchase.
|
|
47
|
+
const replay = {
|
|
48
|
+
...wallet,
|
|
49
|
+
cards: wallet.cards.map(({ milestone_usage: _usage, ...c }) => c),
|
|
50
|
+
};
|
|
51
|
+
let actual = replay;
|
|
52
|
+
let simulated = replay;
|
|
46
53
|
let current = wallet;
|
|
47
54
|
const byCard = new Map();
|
|
48
55
|
const missed = new Map();
|
|
@@ -69,8 +76,40 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
69
76
|
entries: [],
|
|
70
77
|
});
|
|
71
78
|
}
|
|
79
|
+
const milestones = [];
|
|
80
|
+
for (const held of wallet.cards) {
|
|
81
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
82
|
+
if (!card)
|
|
83
|
+
continue;
|
|
84
|
+
for (const p of milestoneProgress(card, held, today))
|
|
85
|
+
if (p.period !== null)
|
|
86
|
+
milestones.push({
|
|
87
|
+
cardId: held.id,
|
|
88
|
+
milestoneId: p.milestone.id,
|
|
89
|
+
period: p.period,
|
|
90
|
+
spend: p.milestone.spend,
|
|
91
|
+
before: p.spent,
|
|
92
|
+
after: p.spent,
|
|
93
|
+
entries: [],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
72
96
|
for (const entry of sorted) {
|
|
73
97
|
const { cardId, line } = entry;
|
|
98
|
+
if (!alreadyRecorded(entry, 'milestone')) {
|
|
99
|
+
const held = wallet.cards.find((c) => c.id === cardId);
|
|
100
|
+
for (const m of milestones) {
|
|
101
|
+
if (m.cardId !== cardId)
|
|
102
|
+
continue;
|
|
103
|
+
const card = catalog.cards.find((c) => c.id === cardId);
|
|
104
|
+
const milestone = card?.milestones.find((x) => x.id === m.milestoneId);
|
|
105
|
+
if (!milestone ||
|
|
106
|
+
periodKey(milestone.period, line.date, held?.anniversary ?? null) !==
|
|
107
|
+
m.period)
|
|
108
|
+
continue;
|
|
109
|
+
m.after = roundCents(Math.max(0, m.after + line.amount));
|
|
110
|
+
m.entries.push(entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
74
113
|
const progress = bonuses.get(cardId);
|
|
75
114
|
if (progress &&
|
|
76
115
|
countsTowardBonus(progress, line.date) &&
|
|
@@ -202,5 +241,6 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
202
241
|
outsideCurrentPeriod,
|
|
203
242
|
alreadyImported,
|
|
204
243
|
bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
|
|
244
|
+
milestones: milestones.filter((m) => m.entries.length > 0),
|
|
205
245
|
};
|
|
206
246
|
}
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
|
3
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
|
+
import { COUNTRY_CODE } from "../schema/common.js";
|
|
6
7
|
export const Merchant = z
|
|
7
8
|
.string()
|
|
8
9
|
.min(1)
|
|
@@ -12,6 +13,11 @@ export const ChannelInput = z
|
|
|
12
13
|
.optional()
|
|
13
14
|
.describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
|
|
14
15
|
export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
|
|
16
|
+
export const CountryInput = z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Where the purchase is made, as a two-letter country code (FR, GB, JP). Pass it when the user is paying abroad or in another currency: abroad, cards are ranked after their foreign transaction fees. Default US.');
|
|
15
21
|
export const CardId = z
|
|
16
22
|
.string()
|
|
17
23
|
.min(1)
|
|
@@ -66,6 +72,10 @@ const cardFields = {
|
|
|
66
72
|
.record(z.string(), z.number().nonnegative())
|
|
67
73
|
.optional()
|
|
68
74
|
.describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
|
|
75
|
+
milestoneSpent: z
|
|
76
|
+
.record(z.string(), z.number().nonnegative())
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Dollars already spent on the card this year, for its spend milestones (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.'),
|
|
69
79
|
bonus: z
|
|
70
80
|
.object({
|
|
71
81
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { CapPeriod } from "../schema/card.js";
|
|
2
|
+
import { CapPeriod, MilestoneSchema } from "../schema/card.js";
|
|
3
|
+
const MilestonePeriod = MilestoneSchema.shape.period;
|
|
3
4
|
import { CardNetwork, Network } from "../schema/common.js";
|
|
4
5
|
// Mirrors the engine's result types. The SDK validates every structured
|
|
5
6
|
// result against these, so a drift between the two fails loudly in tests.
|
|
@@ -38,6 +39,10 @@ const RankedCardSchema = z.object({
|
|
|
38
39
|
centsPerUnit: z.number(),
|
|
39
40
|
effectiveReturnPct: z.number(),
|
|
40
41
|
earned: z.number().nullable(),
|
|
42
|
+
foreignFee: z.number().nullable(),
|
|
43
|
+
milestoneBonus: z
|
|
44
|
+
.object({ points: z.number(), dollars: z.number() })
|
|
45
|
+
.nullable(),
|
|
41
46
|
capped: z.boolean(),
|
|
42
47
|
reasoning: z.array(z.string()),
|
|
43
48
|
warnings: z.array(z.string()),
|
|
@@ -88,7 +93,14 @@ export const WalletSummarySchema = z.object({
|
|
|
88
93
|
started: z.string().nullable(),
|
|
89
94
|
checklist: z
|
|
90
95
|
.array(z.object({
|
|
91
|
-
kind: z.enum([
|
|
96
|
+
kind: z.enum([
|
|
97
|
+
'activate',
|
|
98
|
+
'pick',
|
|
99
|
+
'cap',
|
|
100
|
+
'bonus',
|
|
101
|
+
'milestone',
|
|
102
|
+
'rotation',
|
|
103
|
+
]),
|
|
92
104
|
cardId: z.string(),
|
|
93
105
|
text: z.string(),
|
|
94
106
|
due: z.string().nullable(),
|
|
@@ -128,9 +140,22 @@ export const WalletSummarySchema = z.object({
|
|
|
128
140
|
amount: z.number(),
|
|
129
141
|
period: CapPeriod,
|
|
130
142
|
currentPeriod: z.string().nullable(),
|
|
143
|
+
until: z.string().nullable(),
|
|
131
144
|
spent: z.number(),
|
|
132
145
|
remaining: z.number().nullable(),
|
|
133
146
|
})),
|
|
147
|
+
milestones: z.array(z.object({
|
|
148
|
+
id: z.string(),
|
|
149
|
+
spend: z.number(),
|
|
150
|
+
period: MilestonePeriod,
|
|
151
|
+
currentPeriod: z.string().nullable(),
|
|
152
|
+
until: z.string().nullable(),
|
|
153
|
+
spent: z.number(),
|
|
154
|
+
tracked: z.boolean(),
|
|
155
|
+
points: z.number().nullable(),
|
|
156
|
+
rewards: z.array(z.string()),
|
|
157
|
+
text: z.string(),
|
|
158
|
+
})),
|
|
134
159
|
chosen: z
|
|
135
160
|
.object({
|
|
136
161
|
period: z.string(),
|
|
@@ -168,6 +193,14 @@ export const RecordResultSchema = z.object({
|
|
|
168
193
|
deadline: z.string().nullable(),
|
|
169
194
|
})
|
|
170
195
|
.nullable(),
|
|
196
|
+
milestones: z.array(z.object({
|
|
197
|
+
cardId: z.string(),
|
|
198
|
+
milestoneId: z.string(),
|
|
199
|
+
period: z.string(),
|
|
200
|
+
spend: z.number(),
|
|
201
|
+
before: z.number(),
|
|
202
|
+
after: z.number(),
|
|
203
|
+
})),
|
|
171
204
|
message: z.string(),
|
|
172
205
|
});
|
|
173
206
|
export const OfferComparisonSchema = z.object({
|
|
@@ -207,6 +240,7 @@ export const CardSearchSchema = z.object({
|
|
|
207
240
|
network: CardNetwork,
|
|
208
241
|
currency: z.string(),
|
|
209
242
|
annualFee: z.number().nullable(),
|
|
243
|
+
foreignTransactionFee: z.number().nullable(),
|
|
210
244
|
business: z.boolean(),
|
|
211
245
|
onlyAt: z.array(z.string()).nullable(),
|
|
212
246
|
held: z.boolean(),
|
|
@@ -228,6 +262,14 @@ export const CardSearchSchema = z.object({
|
|
|
228
262
|
.nullable(),
|
|
229
263
|
activation: z.array(z.string()),
|
|
230
264
|
needsAnniversary: z.boolean(),
|
|
265
|
+
milestones: z.array(z.object({
|
|
266
|
+
id: z.string(),
|
|
267
|
+
spend: z.number(),
|
|
268
|
+
period: MilestonePeriod,
|
|
269
|
+
points: z.number().nullable(),
|
|
270
|
+
rewards: z.array(z.string()),
|
|
271
|
+
text: z.string(),
|
|
272
|
+
})),
|
|
231
273
|
})),
|
|
232
274
|
});
|
|
233
275
|
export const WalletChangeSchema = z.object({
|
|
@@ -309,6 +351,12 @@ const SimulationSchema = z.object({
|
|
|
309
351
|
returnPct: z.number(),
|
|
310
352
|
cards: z.array(z.string()),
|
|
311
353
|
})),
|
|
354
|
+
milestones: z.array(z.object({
|
|
355
|
+
cardId: z.string(),
|
|
356
|
+
milestoneId: z.string(),
|
|
357
|
+
points: z.number().nullable(),
|
|
358
|
+
value: z.number(),
|
|
359
|
+
})),
|
|
312
360
|
notes: z.array(z.string()),
|
|
313
361
|
});
|
|
314
362
|
export const SpendingSimulationSchema = z.object({
|
package/dist/mcp/server.js
CHANGED
|
@@ -45,7 +45,23 @@ The wallet lists cards by product id, never card numbers; never ask for card num
|
|
|
45
45
|
|
|
46
46
|
${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
|
|
47
47
|
export function createEarnrateServer(deps) {
|
|
48
|
-
const server = new McpServer({
|
|
48
|
+
const server = new McpServer({
|
|
49
|
+
name: MCP_SERVER_NAME,
|
|
50
|
+
version: deps.version,
|
|
51
|
+
websiteUrl: 'https://earnrate.dev',
|
|
52
|
+
icons: [
|
|
53
|
+
{
|
|
54
|
+
src: 'https://earnrate.dev/icon-192.png',
|
|
55
|
+
mimeType: 'image/png',
|
|
56
|
+
sizes: ['192x192'],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
src: 'https://earnrate.dev/favicon.svg',
|
|
60
|
+
mimeType: 'image/svg+xml',
|
|
61
|
+
sizes: ['any'],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}, { instructions: instructions(deps.savedWallets) });
|
|
49
65
|
const ctx = makeContext(deps);
|
|
50
66
|
const inputs = makeInputs(deps.catalog);
|
|
51
67
|
registerAnswerTools(server, deps, ctx, inputs);
|
|
@@ -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 the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage and
|
|
23
|
+
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, bonus progress and yearly spending toward spend bonuses. Never card numbers.',
|
|
24
24
|
inputSchema: z.object({}),
|
|
25
25
|
outputSchema: z.object({ yaml: z.string() }),
|
|
26
26
|
annotations: {
|
|
@@ -15,7 +15,7 @@ import { formatOffers } from "../../format/offers.js";
|
|
|
15
15
|
import { formatRecommendation } from "../../format/recommendation.js";
|
|
16
16
|
import { resolvePlace, withCallerCategory } from "../../merchants/resolve.js";
|
|
17
17
|
import { ALL_CARDS_NOTE, fail } from "../context.js";
|
|
18
|
-
import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
18
|
+
import { ChannelInput, CountryInput, DateInput, Merchant, settings, } from "../inputs.js";
|
|
19
19
|
import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
|
|
20
20
|
const SOURCES = {
|
|
21
21
|
override: "from earnrate's merchant list",
|
|
@@ -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 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.",
|
|
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. Never needs or returns card numbers.",
|
|
38
38
|
inputSchema: z.object({
|
|
39
39
|
merchant: Merchant,
|
|
40
40
|
amount: z
|
|
@@ -44,6 +44,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
44
44
|
.describe('Purchase amount in US dollars. Improves cap math.'),
|
|
45
45
|
channel: ChannelInput,
|
|
46
46
|
date: DateInput,
|
|
47
|
+
country: CountryInput,
|
|
47
48
|
category: CategoryInput,
|
|
48
49
|
cards: NamedCards,
|
|
49
50
|
memberships: NamedMemberships,
|
|
@@ -55,7 +56,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
55
56
|
readOnlyHint: true,
|
|
56
57
|
openWorldHint: deps.llm !== null,
|
|
57
58
|
},
|
|
58
|
-
}, async ({ merchant, amount, channel, date, category, cards, memberships, valuation, }) => {
|
|
59
|
+
}, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
|
|
59
60
|
const held = await ctx.cardsFor(cards, memberships);
|
|
60
61
|
if (typeof held === 'string')
|
|
61
62
|
return fail(held);
|
|
@@ -67,7 +68,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
67
68
|
amount: amount ?? null,
|
|
68
69
|
channel: channel ?? null,
|
|
69
70
|
date: date ?? deps.today(),
|
|
70
|
-
country: 'US',
|
|
71
|
+
country: (country ?? 'US').toUpperCase(),
|
|
71
72
|
};
|
|
72
73
|
const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
|
|
73
74
|
const retry = resolved.merchant.source === 'none'
|
|
@@ -80,6 +81,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
80
81
|
amount: purchase.amount,
|
|
81
82
|
channel: purchase.channel,
|
|
82
83
|
date: purchase.date,
|
|
84
|
+
country: purchase.country,
|
|
83
85
|
}, settings({ memberships, valuation }, await ctx.saved()), resolved.merchant);
|
|
84
86
|
const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
|
|
85
87
|
const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
|
|
@@ -210,6 +212,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
210
212
|
.optional()
|
|
211
213
|
.describe('Purchase amount in US dollars, if known.'),
|
|
212
214
|
channel: ChannelInput,
|
|
215
|
+
country: CountryInput,
|
|
213
216
|
...CatalogFilters,
|
|
214
217
|
}),
|
|
215
218
|
outputSchema: ExplorationSchema,
|
|
@@ -228,6 +231,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
228
231
|
amount: args.amount ?? null,
|
|
229
232
|
channel,
|
|
230
233
|
date: deps.today(),
|
|
234
|
+
country: (args.country ?? 'US').toUpperCase(),
|
|
231
235
|
}, settings(args, await ctx.saved()), args.merchant === undefined
|
|
232
236
|
? undefined
|
|
233
237
|
: withCallerCategory(resolvePlace(catalog, args.merchant, channel), args.category ?? null));
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -4,6 +4,7 @@ import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../
|
|
|
4
4
|
import { membershipName } from "../../engine/memberships.js";
|
|
5
5
|
import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
|
|
6
6
|
import { optionName } from "../../engine/rule-text.js";
|
|
7
|
+
import { currencyChanges } from "../../engine/wallet-change.js";
|
|
7
8
|
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
8
9
|
import { checklist } from "../../engine/checklist.js";
|
|
9
10
|
import { cardText } from "../../format/card-text.js";
|
|
@@ -44,6 +45,8 @@ function changes(catalog, args) {
|
|
|
44
45
|
: `activated: ${args.activated.join(', ')}`);
|
|
45
46
|
for (const [key, spent] of Object.entries(args.capSpent ?? {}))
|
|
46
47
|
out.push(`${money(spent)} already spent on ${key} this period`);
|
|
48
|
+
for (const [id, spent] of Object.entries(args.milestoneSpent ?? {}))
|
|
49
|
+
out.push(`${money(spent)} spent toward ${id} this year`);
|
|
47
50
|
if (args.bonus === null)
|
|
48
51
|
out.push('welcome bonus no longer tracked');
|
|
49
52
|
else if (args.bonus !== undefined)
|
|
@@ -55,7 +58,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
55
58
|
const { catalog, store } = deps;
|
|
56
59
|
server.registerTool('list_wallet', {
|
|
57
60
|
title: 'List wallet',
|
|
58
|
-
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); 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, 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}`,
|
|
61
|
+
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); 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, 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}`,
|
|
59
62
|
outputSchema: WalletSummarySchema,
|
|
60
63
|
annotations: {
|
|
61
64
|
title: 'List wallet',
|
|
@@ -88,7 +91,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
88
91
|
});
|
|
89
92
|
server.registerTool('record_purchase', {
|
|
90
93
|
title: 'Record a purchase',
|
|
91
|
-
description: `Records a purchase the user has already made, so spending caps
|
|
94
|
+
description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress and yearly spend milestones 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 and no spend milestone 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}`,
|
|
92
95
|
inputSchema: z.object({
|
|
93
96
|
cardId: z
|
|
94
97
|
.string()
|
|
@@ -127,19 +130,21 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
127
130
|
const result = await store.record(purchase, resolved.merchant);
|
|
128
131
|
if (!result.ok)
|
|
129
132
|
return fail(`Couldn't record it: ${result.error}`);
|
|
130
|
-
const { cap: booking, bonus } = result;
|
|
131
|
-
const output = booking || bonus
|
|
133
|
+
const { cap: booking, bonus, milestones } = result;
|
|
134
|
+
const output = booking || bonus || milestones.length > 0
|
|
132
135
|
? {
|
|
133
136
|
recorded: true,
|
|
134
137
|
booking,
|
|
135
138
|
bonus,
|
|
136
|
-
|
|
139
|
+
milestones,
|
|
140
|
+
message: `${formatBooking(amount, cardId, booking, bonus, milestones)} Saved in ${ctx.walletName}.`,
|
|
137
141
|
}
|
|
138
142
|
: {
|
|
139
143
|
recorded: false,
|
|
140
144
|
booking: null,
|
|
141
145
|
bonus: null,
|
|
142
|
-
|
|
146
|
+
milestones: [],
|
|
147
|
+
message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus or spend milestone for this purchase, so the wallet wasn't changed.`,
|
|
143
148
|
};
|
|
144
149
|
return {
|
|
145
150
|
content: [
|
|
@@ -173,7 +178,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
173
178
|
});
|
|
174
179
|
server.registerTool('update_card', {
|
|
175
180
|
title: 'Update a card',
|
|
176
|
-
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+. ${ctx.walletUse}`,
|
|
181
|
+
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), spending this year toward its spend milestones (milestoneSpent) 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}`,
|
|
177
182
|
inputSchema: CardChange,
|
|
178
183
|
outputSchema: WalletChangeSchema,
|
|
179
184
|
annotations: {
|
|
@@ -192,7 +197,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
192
197
|
});
|
|
193
198
|
server.registerTool('remove_card', {
|
|
194
199
|
title: 'Remove a card',
|
|
195
|
-
description: `Removes a card from the wallet, with its tracked cap usage and
|
|
200
|
+
description: `Removes a card from the wallet, with its tracked cap usage, bonus progress and yearly spending. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
|
|
196
201
|
inputSchema: z.object({ cardId: CardId }),
|
|
197
202
|
outputSchema: WalletChangeSchema,
|
|
198
203
|
annotations: {
|
|
@@ -226,7 +231,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
226
231
|
});
|
|
227
232
|
server.registerTool('set_point_values', {
|
|
228
233
|
title: 'Set point values',
|
|
229
|
-
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. No saved cards are needed. 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(', ')}. ${ctx.walletUse}`,
|
|
234
|
+
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. No saved cards are needed. 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 by id: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}. ${ctx.walletUse}`,
|
|
230
235
|
inputSchema: z.object({
|
|
231
236
|
values: z
|
|
232
237
|
.record(z.string(), z.number().nullable())
|
|
@@ -245,9 +250,12 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
245
250
|
idempotentHint: true,
|
|
246
251
|
},
|
|
247
252
|
}, async ({ values, profile }) => {
|
|
253
|
+
const given = currencyChanges(catalog, values ?? {});
|
|
254
|
+
if (!given.ok)
|
|
255
|
+
return fail(given.error);
|
|
248
256
|
const changes = {
|
|
249
257
|
...(profile ? VALUATION_PROFILES[profile] : {}),
|
|
250
|
-
...
|
|
258
|
+
...given.changes,
|
|
251
259
|
};
|
|
252
260
|
if (Object.keys(changes).length === 0)
|
|
253
261
|
return fail('Give values (cents per point by currency) or a profile.');
|
package/dist/schema/card.js
CHANGED
|
@@ -12,6 +12,20 @@ export const CapSchema = z.strictObject({
|
|
|
12
12
|
period: CapPeriod,
|
|
13
13
|
then_rate: z.number().nonnegative(),
|
|
14
14
|
});
|
|
15
|
+
// A bonus paid once a period after a spending total, rather than per dollar
|
|
16
|
+
// ("10,000 bonus points after you spend $15,000 on purchases each calendar
|
|
17
|
+
// year"). Every purchase on the card counts. Only `points` is valued;
|
|
18
|
+
// `rewards` (status, night credits, free nights) is shown, never valued.
|
|
19
|
+
export const MilestoneSchema = z
|
|
20
|
+
.strictObject({
|
|
21
|
+
id: KebabId,
|
|
22
|
+
spend: z.number().positive(), // dollars of purchases in the period
|
|
23
|
+
period: z.enum(['calendar_year', 'cardmember_year']),
|
|
24
|
+
points: z.number().positive().optional(), // in the card's currency
|
|
25
|
+
rewards: z.array(z.string().min(1)).min(1).optional(),
|
|
26
|
+
note: z.string().optional(),
|
|
27
|
+
})
|
|
28
|
+
.refine((m) => m.points !== undefined || m.rewards !== undefined, 'a milestone needs points, rewards or both');
|
|
15
29
|
export const SharedCapSchema = CapSchema.extend({
|
|
16
30
|
id: KebabId,
|
|
17
31
|
// Rotating categories (Freedom Flex): every rule on this cap covers one
|
|
@@ -47,7 +61,10 @@ export const RuleSchema = z
|
|
|
47
61
|
exclude: ExcludeSchema.optional(),
|
|
48
62
|
rate: z.number().nonnegative(), // currency units per dollar
|
|
49
63
|
channel: z.enum(['any', 'online', 'in_store']).default('any'),
|
|
50
|
-
|
|
64
|
+
// A purchase is foreign when its country isn't the U.S.
|
|
65
|
+
geography: z
|
|
66
|
+
.enum(['us_only', 'foreign_only', 'worldwide'])
|
|
67
|
+
.default('worldwide'),
|
|
51
68
|
// Inline cap, or the id of a shared cap in the card's `caps`.
|
|
52
69
|
cap: z.union([CapSchema, KebabId]).optional(),
|
|
53
70
|
requires: z
|
|
@@ -148,6 +165,9 @@ export const CardSchema = z
|
|
|
148
165
|
annual_fee: z.number().nonnegative().optional(), // dollars
|
|
149
166
|
// When the first year differs, usually a waived fee.
|
|
150
167
|
first_year_fee: z.number().nonnegative().optional(),
|
|
168
|
+
// Percent of each purchase outside the U.S.; 0 when there's none. null
|
|
169
|
+
// only when no issuer page states it, with a todo saying so.
|
|
170
|
+
foreign_transaction_fee: z.number().nonnegative().nullable(),
|
|
151
171
|
welcome_offer: WelcomeOfferSchema.optional(),
|
|
152
172
|
// A paid membership (data/memberships.yaml) the card can't be had
|
|
153
173
|
// without, Costco's for Costco Anywhere. Its fee counts like an annual
|
|
@@ -162,6 +182,7 @@ export const CardSchema = z
|
|
|
162
182
|
caps: z.array(SharedCapSchema).default([]),
|
|
163
183
|
choices: z.array(ChoiceSchema).default([]),
|
|
164
184
|
rules: z.array(RuleSchema).default([]),
|
|
185
|
+
milestones: z.array(MilestoneSchema).default([]),
|
|
165
186
|
// A short quote from an issuer page for each rule (or `base_rate`). The
|
|
166
187
|
// weekly source check fails a rule whose quote disappears from its page.
|
|
167
188
|
evidence: z
|
package/dist/schema/common.js
CHANGED
|
@@ -23,3 +23,7 @@ export const PeriodKey = z.preprocess((v) => (typeof v === 'number' ? String(v)
|
|
|
23
23
|
export const ValueRange = z
|
|
24
24
|
.tuple([z.number().positive(), z.number().positive()])
|
|
25
25
|
.refine(([low, high]) => low <= high, 'range must be [low, high]');
|
|
26
|
+
// A two-letter country code, like US or FR. Only the U.S. versus everywhere
|
|
27
|
+
// else matters to the engine.
|
|
28
|
+
export const COUNTRY_CODE = /^[A-Za-z]{2}$/;
|
|
29
|
+
export const countryProblem = (code) => `"${code}" isn't a country code. Use two letters, like US or FR.`;
|
package/dist/schema/wallet.js
CHANGED
|
@@ -37,6 +37,8 @@ export const WalletCardSchema = z.strictObject({
|
|
|
37
37
|
})
|
|
38
38
|
.optional(),
|
|
39
39
|
activated: z.array(KebabId).default([]),
|
|
40
|
+
// Keyed by milestone id: every purchase on the card this period.
|
|
41
|
+
milestone_usage: z.record(KebabId, CapUsage).optional(),
|
|
40
42
|
// A welcome bonus in progress. Terms default to the card's published
|
|
41
43
|
// offer; set spend, by, points or dollars when yours was different.
|
|
42
44
|
bonus: z
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "earnrate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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",
|
|
@@ -46,13 +46,15 @@
|
|
|
46
46
|
"remote:build": "node scripts/build-remote.ts",
|
|
47
47
|
"remote:check": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc --dry-run --outdir dist",
|
|
48
48
|
"remote:deploy": "yarn remote:build && WRANGLER_SEND_METRICS=false wrangler deploy --config remote/wrangler.jsonc",
|
|
49
|
+
"monitor:check": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc --dry-run --outdir dist",
|
|
50
|
+
"monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
|
|
49
51
|
"site:build": "vite build site && node scripts/build-pages.ts",
|
|
50
52
|
"site:dev": "vite site",
|
|
51
53
|
"site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
|
|
52
54
|
"test": "vitest run",
|
|
53
55
|
"test:watch": "vitest",
|
|
54
56
|
"coverage": "vitest run --coverage --coverage.include=src/**",
|
|
55
|
-
"typecheck": "tsc && tsc -p site && tsc -p remote",
|
|
57
|
+
"typecheck": "tsc && tsc -p site && tsc -p remote && tsc -p monitor",
|
|
56
58
|
"lint": "eslint",
|
|
57
59
|
"format": "prettier --write .",
|
|
58
60
|
"format:check": "prettier --check .",
|
|
@@ -81,6 +83,6 @@
|
|
|
81
83
|
"typescript-eslint": "^8.72.0",
|
|
82
84
|
"vite": "^8.3.5",
|
|
83
85
|
"vitest": "^5.0.5",
|
|
84
|
-
"wrangler": "4.
|
|
86
|
+
"wrangler": "4.156.2"
|
|
85
87
|
}
|
|
86
88
|
}
|