earnrate 0.2.8 → 0.2.9
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 +133 -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 +36 -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 +29 -1
- 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 +284 -0
- package/data/merchants/seed.yaml +1055 -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 +1 -1
- package/dist/engine/milestones.js +83 -0
- package/dist/engine/periods.js +29 -12
- package/dist/engine/recommend.js +39 -9
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +5 -1
- 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/wallet.js +18 -4
- package/dist/import/analyze.js +42 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/schemas.js +47 -2
- 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/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,7 @@ 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(),
|
|
41
43
|
capped: z.boolean(),
|
|
42
44
|
reasoning: z.array(z.string()),
|
|
43
45
|
warnings: z.array(z.string()),
|
|
@@ -88,7 +90,14 @@ export const WalletSummarySchema = z.object({
|
|
|
88
90
|
started: z.string().nullable(),
|
|
89
91
|
checklist: z
|
|
90
92
|
.array(z.object({
|
|
91
|
-
kind: z.enum([
|
|
93
|
+
kind: z.enum([
|
|
94
|
+
'activate',
|
|
95
|
+
'pick',
|
|
96
|
+
'cap',
|
|
97
|
+
'bonus',
|
|
98
|
+
'milestone',
|
|
99
|
+
'rotation',
|
|
100
|
+
]),
|
|
92
101
|
cardId: z.string(),
|
|
93
102
|
text: z.string(),
|
|
94
103
|
due: z.string().nullable(),
|
|
@@ -128,9 +137,22 @@ export const WalletSummarySchema = z.object({
|
|
|
128
137
|
amount: z.number(),
|
|
129
138
|
period: CapPeriod,
|
|
130
139
|
currentPeriod: z.string().nullable(),
|
|
140
|
+
until: z.string().nullable(),
|
|
131
141
|
spent: z.number(),
|
|
132
142
|
remaining: z.number().nullable(),
|
|
133
143
|
})),
|
|
144
|
+
milestones: z.array(z.object({
|
|
145
|
+
id: z.string(),
|
|
146
|
+
spend: z.number(),
|
|
147
|
+
period: MilestonePeriod,
|
|
148
|
+
currentPeriod: z.string().nullable(),
|
|
149
|
+
until: z.string().nullable(),
|
|
150
|
+
spent: z.number(),
|
|
151
|
+
tracked: z.boolean(),
|
|
152
|
+
points: z.number().nullable(),
|
|
153
|
+
rewards: z.array(z.string()),
|
|
154
|
+
text: z.string(),
|
|
155
|
+
})),
|
|
134
156
|
chosen: z
|
|
135
157
|
.object({
|
|
136
158
|
period: z.string(),
|
|
@@ -168,6 +190,14 @@ export const RecordResultSchema = z.object({
|
|
|
168
190
|
deadline: z.string().nullable(),
|
|
169
191
|
})
|
|
170
192
|
.nullable(),
|
|
193
|
+
milestones: z.array(z.object({
|
|
194
|
+
cardId: z.string(),
|
|
195
|
+
milestoneId: z.string(),
|
|
196
|
+
period: z.string(),
|
|
197
|
+
spend: z.number(),
|
|
198
|
+
before: z.number(),
|
|
199
|
+
after: z.number(),
|
|
200
|
+
})),
|
|
171
201
|
message: z.string(),
|
|
172
202
|
});
|
|
173
203
|
export const OfferComparisonSchema = z.object({
|
|
@@ -207,6 +237,7 @@ export const CardSearchSchema = z.object({
|
|
|
207
237
|
network: CardNetwork,
|
|
208
238
|
currency: z.string(),
|
|
209
239
|
annualFee: z.number().nullable(),
|
|
240
|
+
foreignTransactionFee: z.number().nullable(),
|
|
210
241
|
business: z.boolean(),
|
|
211
242
|
onlyAt: z.array(z.string()).nullable(),
|
|
212
243
|
held: z.boolean(),
|
|
@@ -228,6 +259,14 @@ export const CardSearchSchema = z.object({
|
|
|
228
259
|
.nullable(),
|
|
229
260
|
activation: z.array(z.string()),
|
|
230
261
|
needsAnniversary: z.boolean(),
|
|
262
|
+
milestones: z.array(z.object({
|
|
263
|
+
id: z.string(),
|
|
264
|
+
spend: z.number(),
|
|
265
|
+
period: MilestonePeriod,
|
|
266
|
+
points: z.number().nullable(),
|
|
267
|
+
rewards: z.array(z.string()),
|
|
268
|
+
text: z.string(),
|
|
269
|
+
})),
|
|
231
270
|
})),
|
|
232
271
|
});
|
|
233
272
|
export const WalletChangeSchema = z.object({
|
|
@@ -309,6 +348,12 @@ const SimulationSchema = z.object({
|
|
|
309
348
|
returnPct: z.number(),
|
|
310
349
|
cards: z.array(z.string()),
|
|
311
350
|
})),
|
|
351
|
+
milestones: z.array(z.object({
|
|
352
|
+
cardId: z.string(),
|
|
353
|
+
milestoneId: z.string(),
|
|
354
|
+
points: z.number().nullable(),
|
|
355
|
+
value: z.number(),
|
|
356
|
+
})),
|
|
312
357
|
notes: z.array(z.string()),
|
|
313
358
|
});
|
|
314
359
|
export const SpendingSimulationSchema = z.object({
|
|
@@ -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.9",
|
|
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
|
}
|