earnrate 0.2.16 → 0.2.18
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 +22 -10
- package/data/CHANGELOG.md +33 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +9 -12
- package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
- package/data/cards/chase-ihg-one-rewards.yaml +13 -2
- package/data/cards/chase-ink-business-cash.yaml +5 -1
- package/data/cards/chase-ink-business-unlimited.yaml +5 -1
- package/data/cards/chase-prime-visa.yaml +3 -3
- package/data/cards/chase-sapphire-reserve.yaml +12 -3
- package/data/cards/chase-united-business.yaml +8 -1
- package/data/cards/chase-united-club-business.yaml +10 -1
- package/data/cards/chase-united-club.yaml +5 -1
- package/data/cards/chase-united-explorer.yaml +5 -1
- package/data/cards/chase-united-quest.yaml +5 -1
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -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 +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +16 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/changes.yaml +2840 -0
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +18 -3
- package/dist/cli/wallet.js +46 -11
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +20 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +21 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +42 -7
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +52 -15
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +60 -6
- package/dist/engine/periods.js +7 -0
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +21 -13
- package/dist/engine/setup.js +12 -8
- package/dist/engine/simulate.js +57 -17
- package/dist/engine/text.js +110 -5
- package/dist/engine/wallet-change.js +34 -15
- package/dist/engine/wallet-summary.js +29 -10
- package/dist/format/card-text.js +10 -11
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +8 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +12 -2
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +47 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +33 -9
- package/dist/schema/card.js +18 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +11 -3
- package/package.json +1 -1
package/dist/mcp/schemas.js
CHANGED
|
@@ -96,6 +96,7 @@ const RecommendationSchema = z.object({
|
|
|
96
96
|
export const WalletSummarySchema = z.object({
|
|
97
97
|
memberships: z.array(z.string()),
|
|
98
98
|
membershipNames: z.array(z.string()),
|
|
99
|
+
skippedIssuers: z.array(z.object({ id: z.string(), name: z.string() })),
|
|
99
100
|
pointValues: z.array(z.object({
|
|
100
101
|
currency: z.string(),
|
|
101
102
|
name: z.string(),
|
|
@@ -115,6 +116,7 @@ export const WalletSummarySchema = z.object({
|
|
|
115
116
|
'intro',
|
|
116
117
|
'credit',
|
|
117
118
|
'enroll',
|
|
119
|
+
'renewal',
|
|
118
120
|
]),
|
|
119
121
|
cardId: z.string(),
|
|
120
122
|
ref: z.string().optional(),
|
|
@@ -179,6 +181,8 @@ export const WalletSummarySchema = z.object({
|
|
|
179
181
|
period: CreditPeriod,
|
|
180
182
|
enroll: z.boolean(),
|
|
181
183
|
enrolled: z.boolean(),
|
|
184
|
+
skipped: z.boolean(), // the user doesn't use it: not counted anywhere
|
|
185
|
+
confirmed: z.boolean(), // said in use: counts against the annual fee
|
|
182
186
|
currentPeriod: z.string().nullable(),
|
|
183
187
|
until: z.string().nullable(),
|
|
184
188
|
used: z.number(),
|
|
@@ -186,6 +190,8 @@ export const WalletSummarySchema = z.object({
|
|
|
186
190
|
tracked: z.boolean(),
|
|
187
191
|
text: z.string(),
|
|
188
192
|
})),
|
|
193
|
+
usedCredits: z.number(), // a year, counted against the annual fee
|
|
194
|
+
usedCreditsText: z.string().nullable(),
|
|
189
195
|
chosen: z
|
|
190
196
|
.object({
|
|
191
197
|
period: z.string(),
|
|
@@ -282,11 +288,28 @@ export const OfferComparisonSchema = z.object({
|
|
|
282
288
|
ineligible: z.string().nullable(),
|
|
283
289
|
bonusValue: z.number(),
|
|
284
290
|
firstYearValue: z.number(),
|
|
291
|
+
// Statement credits, a year at face value, and the first-year value
|
|
292
|
+
// if all were used. Not ranked on.
|
|
293
|
+
credits: z.number(),
|
|
294
|
+
firstYearValueIfCreditsUsed: z.number(),
|
|
295
|
+
// On the user's spending beside their cards; null for a card they hold.
|
|
296
|
+
plan: z
|
|
297
|
+
.object({
|
|
298
|
+
extraRewards: z.number(),
|
|
299
|
+
minimumSpend: z
|
|
300
|
+
.object({ reachable: z.boolean(), weeks: z.number() })
|
|
301
|
+
.nullable(),
|
|
302
|
+
yearOne: z.number(),
|
|
303
|
+
yearTwo: z.number(),
|
|
304
|
+
twoYears: z.number(),
|
|
305
|
+
})
|
|
306
|
+
.nullable(),
|
|
285
307
|
held: z.boolean(),
|
|
286
308
|
lastVerified: z.string(),
|
|
287
309
|
verifiedBy: z.string(),
|
|
288
310
|
})),
|
|
289
311
|
withoutOffer: z.array(z.string()),
|
|
312
|
+
typical: z.boolean(),
|
|
290
313
|
notes: z.array(z.string()),
|
|
291
314
|
});
|
|
292
315
|
export const CardSearchSchema = z.object({
|
|
@@ -312,6 +335,7 @@ export const CardSearchSchema = z.object({
|
|
|
312
335
|
'calendar_year',
|
|
313
336
|
'until_changed',
|
|
314
337
|
]),
|
|
338
|
+
automatic: z.boolean(),
|
|
315
339
|
options: z.array(z.string()),
|
|
316
340
|
default: z.array(z.string()).nullable(),
|
|
317
341
|
})),
|
|
@@ -381,6 +405,7 @@ export const ExplorationSchema = z.object({
|
|
|
381
405
|
enroll: z.boolean(),
|
|
382
406
|
note: z.string().nullable(),
|
|
383
407
|
back: z.string(),
|
|
408
|
+
terms: z.string(),
|
|
384
409
|
earn: z.string(),
|
|
385
410
|
text: z.string(),
|
|
386
411
|
evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
|
|
@@ -397,6 +422,7 @@ export const ExplorationSchema = z.object({
|
|
|
397
422
|
choice: z.string(),
|
|
398
423
|
options: z.array(z.string()),
|
|
399
424
|
period: z.string(),
|
|
425
|
+
automatic: z.boolean(),
|
|
400
426
|
})
|
|
401
427
|
.nullable(),
|
|
402
428
|
activation: z.string().nullable(),
|
|
@@ -418,6 +444,13 @@ export const ExplorationSchema = z.object({
|
|
|
418
444
|
excluded: z.array(ExcludedCardSchema),
|
|
419
445
|
assumptions: z.array(z.string()),
|
|
420
446
|
warnings: z.array(z.string()),
|
|
447
|
+
// Ways of paying that would put a card at the top, when payment is unknown.
|
|
448
|
+
ifPaying: z.array(z.object({
|
|
449
|
+
cardId: z.string(),
|
|
450
|
+
cardName: z.string(),
|
|
451
|
+
payment: z.string(),
|
|
452
|
+
earn: z.string(),
|
|
453
|
+
})),
|
|
421
454
|
});
|
|
422
455
|
const SimulationSchema = z.object({
|
|
423
456
|
cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
|
|
@@ -431,7 +464,19 @@ const SimulationSchema = z.object({
|
|
|
431
464
|
cardIds: z.array(z.string()),
|
|
432
465
|
})),
|
|
433
466
|
membershipFees: z.number(),
|
|
467
|
+
// Credits the user uses on cards they hold, a year: counted in net.
|
|
468
|
+
heldCredits: z.number(),
|
|
434
469
|
net: z.number(),
|
|
470
|
+
// Credits on cards not held, a year at face value: not in net.
|
|
471
|
+
otherCredits: z.number(),
|
|
472
|
+
netIfCreditsUsed: z.number(),
|
|
473
|
+
credits: z.array(z.object({
|
|
474
|
+
cardId: z.string(),
|
|
475
|
+
yearly: z.number(),
|
|
476
|
+
creditIds: z.array(z.string()),
|
|
477
|
+
names: z.array(z.string()),
|
|
478
|
+
held: z.boolean(),
|
|
479
|
+
})),
|
|
435
480
|
lines: z.array(z.object({
|
|
436
481
|
label: z.string(),
|
|
437
482
|
spend: z.number(),
|
|
@@ -459,6 +504,8 @@ export const SpendingSimulationSchema = z.object({
|
|
|
459
504
|
annualFee: z.number(),
|
|
460
505
|
membershipFee: z.number(),
|
|
461
506
|
gain: z.number(),
|
|
507
|
+
credits: z.number(), // its statement credits, a year: not in gain
|
|
508
|
+
gainIfCreditsUsed: z.number(),
|
|
462
509
|
})),
|
|
463
510
|
});
|
|
464
511
|
export const CheatSheetSchema = z.object({
|
|
@@ -57,7 +57,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
57
57
|
const importWallet = importHandler(catalog, store, ctx);
|
|
58
58
|
server.registerTool('export_wallet', {
|
|
59
59
|
title: 'Export the wallet',
|
|
60
|
-
description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
|
|
60
|
+
description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in, used and not used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
|
|
61
61
|
inputSchema: z.object({}),
|
|
62
62
|
outputSchema: z.object({ yaml: z.string() }),
|
|
63
63
|
annotations: {
|
|
@@ -31,7 +31,7 @@ const TOP = 10;
|
|
|
31
31
|
// named in the conversation, or a saved wallet if there is one.
|
|
32
32
|
export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
33
33
|
const { catalog } = deps;
|
|
34
|
-
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
|
|
34
|
+
const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, SkippedIssuers, } = inputs;
|
|
35
35
|
server.registerTool('best_card_for_purchase', {
|
|
36
36
|
title: 'Best card for a purchase',
|
|
37
37
|
description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
|
|
@@ -155,9 +155,34 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
155
155
|
structuredContent: m,
|
|
156
156
|
};
|
|
157
157
|
});
|
|
158
|
+
const SpendingLines = z
|
|
159
|
+
.array(z.object({
|
|
160
|
+
merchant: Merchant.optional(),
|
|
161
|
+
category: CatalogCategory.optional(),
|
|
162
|
+
monthly: z.number().positive().describe('US dollars a month.'),
|
|
163
|
+
}))
|
|
164
|
+
.optional()
|
|
165
|
+
.describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered.");
|
|
166
|
+
// Spending lines as given, or typical amounts; a string is why they can't
|
|
167
|
+
// be used.
|
|
168
|
+
const spendingFrom = (given) => {
|
|
169
|
+
if (given.some((l) => l.merchant === undefined && l.category === undefined))
|
|
170
|
+
return 'Each spending line needs a merchant or a category.';
|
|
171
|
+
const lines = given.length === 0
|
|
172
|
+
? typicalSpendLines(catalog)
|
|
173
|
+
: resolveSpendLines(catalog, given.map((l) => ({
|
|
174
|
+
merchant: l.merchant ?? null,
|
|
175
|
+
category: l.merchant === undefined ? (l.category ?? null) : null,
|
|
176
|
+
monthly: l.monthly,
|
|
177
|
+
})));
|
|
178
|
+
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
179
|
+
if (unknown.length > 0)
|
|
180
|
+
return `earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`;
|
|
181
|
+
return { lines, typical: given.length === 0 };
|
|
182
|
+
};
|
|
158
183
|
server.registerTool('compare_welcome_offers', {
|
|
159
184
|
title: 'Compare welcome offers',
|
|
160
|
-
description: "Ranks
|
|
185
|
+
description: "Ranks cards the user doesn't have by what they'd be worth over the first two years: what each adds to the user's cards (or, with none, earns) on their spending, plus the public welcome (sign-up) bonus in year one if its minimum spend fits in the window, less each year's annual fee and any membership it needs. Each offer also shows the bonus alone (first-year value), year one and year two, and how many weeks of spending the minimum takes. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last; issuers the user can't get cards from (skippedIssuers, or saved with set_memberships) are left out. Use it when the user asks which card to get next or which has the best sign-up bonus. Spending is optional (typical amounts otherwise). It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. Never suggest applying through a link.",
|
|
161
186
|
inputSchema: z.object({
|
|
162
187
|
...OfferFilters,
|
|
163
188
|
valuation: CatalogFilters.valuation,
|
|
@@ -167,6 +192,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
167
192
|
.describe('Include cards already in the wallet.'),
|
|
168
193
|
cards: NamedCards,
|
|
169
194
|
memberships: NamedMemberships,
|
|
195
|
+
spending: SpendingLines,
|
|
196
|
+
skippedIssuers: SkippedIssuers.optional().describe("Issuers the user says they can't or won't get a new card from (for example Chase, over its 5/24 rule), for this answer. Replaces the saved list. Never assume it."),
|
|
170
197
|
}),
|
|
171
198
|
outputSchema: OfferComparisonSchema,
|
|
172
199
|
annotations: {
|
|
@@ -180,15 +207,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
180
207
|
if (typeof wallet === 'string')
|
|
181
208
|
return fail(wallet);
|
|
182
209
|
const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
|
|
183
|
-
const
|
|
210
|
+
const spending = spendingFrom(args.spending ?? []);
|
|
211
|
+
if (typeof spending === 'string')
|
|
212
|
+
return fail(spending);
|
|
213
|
+
const withMembers = args.memberships
|
|
184
214
|
? { ...found, memberships: args.memberships }
|
|
185
215
|
: found;
|
|
216
|
+
const base = args.skippedIssuers
|
|
217
|
+
? { ...withMembers, skipped_issuers: args.skippedIssuers }
|
|
218
|
+
: withMembers;
|
|
186
219
|
const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
|
|
187
220
|
kind: args.kind ?? 'any',
|
|
188
221
|
noAnnualFee: args.noAnnualFee ?? false,
|
|
189
222
|
business: args.business ?? false,
|
|
190
223
|
includeHeld: args.includeHeld ?? false,
|
|
191
|
-
}, deps.today());
|
|
224
|
+
}, deps.today(), spending);
|
|
192
225
|
const trimmed = {
|
|
193
226
|
...comparison,
|
|
194
227
|
// Offers ruled out by a card the user holds sort last; keep them in
|
|
@@ -260,14 +293,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
260
293
|
title: 'Simulate a year of spending',
|
|
261
294
|
description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees, and minus a membership a card needs (a Costco membership) unless the user has it or spends at Costco. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
|
|
262
295
|
inputSchema: z.object({
|
|
263
|
-
spending:
|
|
264
|
-
.array(z.object({
|
|
265
|
-
merchant: Merchant.optional(),
|
|
266
|
-
category: CatalogCategory.optional(),
|
|
267
|
-
monthly: z.number().positive().describe('US dollars a month.'),
|
|
268
|
-
}))
|
|
269
|
-
.optional()
|
|
270
|
-
.describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered."),
|
|
296
|
+
spending: SpendingLines,
|
|
271
297
|
cards: NamedCards,
|
|
272
298
|
compareToWallet: z
|
|
273
299
|
.boolean()
|
|
@@ -284,18 +310,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
284
310
|
},
|
|
285
311
|
}, async (args) => {
|
|
286
312
|
const given = args.spending ?? [];
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
: resolveSpendLines(catalog, given.map((l) => ({
|
|
292
|
-
merchant: l.merchant ?? null,
|
|
293
|
-
category: l.merchant === undefined ? (l.category ?? null) : null,
|
|
294
|
-
monthly: l.monthly,
|
|
295
|
-
})));
|
|
296
|
-
const unknown = lines.filter((l) => l.merchant.source === 'none');
|
|
297
|
-
if (unknown.length > 0)
|
|
298
|
-
return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
|
|
313
|
+
const spending = spendingFrom(given);
|
|
314
|
+
if (typeof spending === 'string')
|
|
315
|
+
return fail(spending);
|
|
316
|
+
const { lines } = spending;
|
|
299
317
|
const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
|
|
300
318
|
const options = {
|
|
301
319
|
...settings(args, await ctx.saved()),
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -7,7 +7,7 @@ import { optionName } from "../../engine/rule-text.js";
|
|
|
7
7
|
import { currencyChanges } from "../../engine/wallet-change.js";
|
|
8
8
|
import { walletSummary } from "../../engine/wallet-summary.js";
|
|
9
9
|
import { checklist } from "../../engine/checklist.js";
|
|
10
|
-
import { cardText } from "../../format/card-text.js";
|
|
10
|
+
import { cardText, issuerName } from "../../format/card-text.js";
|
|
11
11
|
import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
|
|
12
12
|
import { fail } from "../context.js";
|
|
13
13
|
import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
|
|
@@ -57,6 +57,10 @@ function changes(catalog, args) {
|
|
|
57
57
|
: `enrolled in: ${args.enrolled.join(', ')}`);
|
|
58
58
|
for (const [id, used] of Object.entries(args.creditUsed ?? {}))
|
|
59
59
|
out.push(`${money(used)} of the ${id} credit used this period`);
|
|
60
|
+
if (args.skippedCredits !== undefined)
|
|
61
|
+
out.push(args.skippedCredits.length === 0
|
|
62
|
+
? 'every credit counted'
|
|
63
|
+
: `credits not used: ${args.skippedCredits.join(', ')}`);
|
|
60
64
|
if (args.bonus === null)
|
|
61
65
|
out.push('welcome bonus no longer tracked');
|
|
62
66
|
else if (args.bonus !== undefined)
|
|
@@ -183,8 +187,11 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
183
187
|
run: async (args) => {
|
|
184
188
|
const what = changes(catalog, args);
|
|
185
189
|
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
186
|
-
const unused = args.anniversary !== undefined &&
|
|
187
|
-
|
|
190
|
+
const unused = args.anniversary !== undefined &&
|
|
191
|
+
card &&
|
|
192
|
+
!needsAnniversary(card) &&
|
|
193
|
+
(card.annual_fee ?? 0) === 0
|
|
194
|
+
? " It has no annual fee and none of its caps reset on the account anniversary, so earnrate doesn't use it."
|
|
188
195
|
: '';
|
|
189
196
|
return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
|
|
190
197
|
},
|
|
@@ -194,10 +201,27 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
194
201
|
input: RemoveInput,
|
|
195
202
|
run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
|
|
196
203
|
};
|
|
197
|
-
const MembershipsInput = z.object({
|
|
204
|
+
const MembershipsInput = z.object({
|
|
205
|
+
memberships: inputs.AllMemberships.optional(),
|
|
206
|
+
skippedIssuers: inputs.SkippedIssuers.optional().describe("Issuers the user says they can't or won't get a new card from (for example Chase, over its 5/24 rule): left out of compare_welcome_offers and of the cards that would add the most. Replaces the saved list; [] clears it. Ask; never assume."),
|
|
207
|
+
});
|
|
198
208
|
const setMemberships = {
|
|
199
209
|
input: MembershipsInput,
|
|
200
|
-
run: async ({ memberships }) => {
|
|
210
|
+
run: async ({ memberships, skippedIssuers }) => {
|
|
211
|
+
if (memberships === undefined && skippedIssuers === undefined)
|
|
212
|
+
return fail('Give memberships, skippedIssuers or both.');
|
|
213
|
+
let skipText = '';
|
|
214
|
+
if (skippedIssuers !== undefined) {
|
|
215
|
+
const skipped = await store.setSkippedIssuers(skippedIssuers);
|
|
216
|
+
skipText =
|
|
217
|
+
skippedIssuers.length > 0
|
|
218
|
+
? `Next-card answers now leave out ${listed(skippedIssuers.map((i) => issuerName(catalog, i)))}.`
|
|
219
|
+
: 'Next-card answers include every issuer again.';
|
|
220
|
+
if (memberships === undefined || !skipped.ok)
|
|
221
|
+
return ctx.changed(skipped, skipText, { notes: false });
|
|
222
|
+
}
|
|
223
|
+
if (memberships === undefined)
|
|
224
|
+
return fail('Give memberships.');
|
|
201
225
|
const result = await store.setMemberships(memberships);
|
|
202
226
|
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
203
227
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -206,7 +230,7 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
206
230
|
const unused = memberships.filter((m) => !used.has(m));
|
|
207
231
|
return ctx.changed(result, `${memberships.length > 0
|
|
208
232
|
? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
|
|
209
|
-
: 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${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 });
|
|
233
|
+
: 'Memberships cleared.'}${skipText === '' ? '' : ` ${skipText}`}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${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 });
|
|
210
234
|
},
|
|
211
235
|
};
|
|
212
236
|
const PointValuesInput = z.object({
|
|
@@ -255,7 +279,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
255
279
|
const tools = walletHandlers(deps, ctx, inputs);
|
|
256
280
|
server.registerTool('list_wallet', {
|
|
257
281
|
title: 'List wallet',
|
|
258
|
-
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
282
|
+
description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset, and a fee card's renewal check in the month before its anniversary: whether it still pays for itself at typical spending); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
|
|
259
283
|
outputSchema: WalletSummarySchema,
|
|
260
284
|
annotations: {
|
|
261
285
|
title: 'List wallet',
|
|
@@ -291,7 +315,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
291
315
|
}, (args) => tools.add_card.run(args));
|
|
292
316
|
server.registerTool('update_card', {
|
|
293
317
|
title: 'Update a card',
|
|
294
|
-
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend bonuses (milestoneSpent), statement credits enrolled in (enrolled)
|
|
318
|
+
description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend bonuses (milestoneSpent), statement credits enrolled in (enrolled), used this period (creditUsed) or not used at all (skippedCredits), 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}`,
|
|
295
319
|
inputSchema: tools.update_card.input,
|
|
296
320
|
outputSchema: WalletChangeSchema,
|
|
297
321
|
annotations: {
|
|
@@ -315,7 +339,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
315
339
|
}, (args) => tools.remove_card.run(args));
|
|
316
340
|
server.registerTool('set_memberships', {
|
|
317
341
|
title: 'Set memberships',
|
|
318
|
-
description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
|
|
342
|
+
description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. With skippedIssuers, it also saves the issuers the user can't or won't get a new card from (Chase over its 5/24 rule), which next-card answers leave out; either can be given alone. ${ctx.walletUse}`,
|
|
319
343
|
inputSchema: tools.set_memberships.input,
|
|
320
344
|
outputSchema: WalletChangeSchema,
|
|
321
345
|
annotations: {
|
package/dist/schema/card.js
CHANGED
|
@@ -23,6 +23,9 @@ export const MilestoneSchema = z
|
|
|
23
23
|
period: z.enum(['calendar_year', 'cardmember_year']),
|
|
24
24
|
points: z.number().positive().optional(), // in the card's currency
|
|
25
25
|
rewards: z.array(z.string().min(1)).min(1).optional(),
|
|
26
|
+
// The day the issuer starts it, for one announced ahead: spending counts
|
|
27
|
+
// from then.
|
|
28
|
+
valid_from: IsoDate.optional(),
|
|
26
29
|
note: z.string().optional(),
|
|
27
30
|
})
|
|
28
31
|
.refine((m) => m.points !== undefined || m.rewards !== undefined, 'a milestone needs points, rewards or both');
|
|
@@ -76,6 +79,9 @@ export const RuleSchema = z
|
|
|
76
79
|
geography: z
|
|
77
80
|
.enum(['us_only', 'foreign_only', 'worldwide'])
|
|
78
81
|
.default('worldwide'),
|
|
82
|
+
// Only a purchase of at least this many dollars. Unknown without an
|
|
83
|
+
// amount, so then it never applies by assumption.
|
|
84
|
+
min_amount: z.number().positive().optional(),
|
|
79
85
|
// Inline cap, or the id of a shared cap in the card's `caps`.
|
|
80
86
|
cap: z.union([CapSchema, KebabId]).optional(),
|
|
81
87
|
requires: z
|
|
@@ -123,6 +129,10 @@ export const ChoiceSchema = z.strictObject({
|
|
|
123
129
|
// `until_changed`: the pick carries over until the cardholder changes it,
|
|
124
130
|
// so a pick made in an earlier period is still current.
|
|
125
131
|
period: z.enum(['month', 'quarter', 'calendar_year', 'until_changed']),
|
|
132
|
+
// The issuer applies the bonus to the options with the most spending each
|
|
133
|
+
// period (Citi Custom Cash). A wallet's pick is the one the cardholder
|
|
134
|
+
// expects, and wording never says the cardholder picks.
|
|
135
|
+
automatic: z.literal(true).optional(),
|
|
126
136
|
// Option ids that apply when the wallet hasn't picked, e.g. Bilt's dining.
|
|
127
137
|
default: z.array(KebabId).min(1).optional(),
|
|
128
138
|
// Option id → what that option covers: a list of categories; or, when the
|
|
@@ -186,6 +196,11 @@ export const CreditSchema = z.strictObject({
|
|
|
186
196
|
period: CreditPeriod,
|
|
187
197
|
// The cardholder must enroll with the issuer before it pays.
|
|
188
198
|
enroll: z.literal(true).optional(),
|
|
199
|
+
// It pays only on a purchase of at least this many dollars.
|
|
200
|
+
min_amount: z.number().positive().optional(),
|
|
201
|
+
// The issuer's last day for it. Past it the credit counts nowhere, and a
|
|
202
|
+
// year's worth counts only the periods that start by then.
|
|
203
|
+
valid_to: IsoDate.optional(),
|
|
189
204
|
note: z.string().optional(),
|
|
190
205
|
});
|
|
191
206
|
const HexColor = z.string().regex(/^#[0-9a-f]{6}$/);
|
|
@@ -243,6 +258,9 @@ export const CardSchema = z
|
|
|
243
258
|
rule: z.string().min(1),
|
|
244
259
|
url: z.url(),
|
|
245
260
|
quote: z.string().min(1),
|
|
261
|
+
// Set only after seeing the quote in a popup the page builds on
|
|
262
|
+
// click from its embedded data; other quotes must be in its text.
|
|
263
|
+
in_popup: z.literal(true).optional(),
|
|
246
264
|
}))
|
|
247
265
|
.default([]),
|
|
248
266
|
// Known gaps in this file, surfaced as warnings.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CategoryId, CurrencyId, IsoDate, KebabId } from "./common.js";
|
|
3
|
+
// Only `issuer` means the issuer changed its terms, and only after earnrate
|
|
4
|
+
// had recorded the old value correctly. A term earnrate starts recording
|
|
5
|
+
// late is an `addition`, so earnrate's own work never reads as a rate cut.
|
|
6
|
+
export const ChangeCause = z.enum([
|
|
7
|
+
'issuer',
|
|
8
|
+
'correction',
|
|
9
|
+
'addition',
|
|
10
|
+
'wording',
|
|
11
|
+
'quote',
|
|
12
|
+
'review',
|
|
13
|
+
]);
|
|
14
|
+
export const CHANGE_SUBJECTS = [
|
|
15
|
+
'cards',
|
|
16
|
+
'merchants',
|
|
17
|
+
'categories',
|
|
18
|
+
'currencies',
|
|
19
|
+
'memberships',
|
|
20
|
+
'issuers',
|
|
21
|
+
];
|
|
22
|
+
export const DATA_VERSION = /^(\d{4})\.(\d{2})\.(\d{2})(\.\d+)?$/;
|
|
23
|
+
const ids = z.array(KebabId).min(1);
|
|
24
|
+
export const ChangeSchema = z
|
|
25
|
+
.strictObject({
|
|
26
|
+
cause: ChangeCause,
|
|
27
|
+
card: KebabId.optional(),
|
|
28
|
+
cards: ids.optional(),
|
|
29
|
+
merchants: ids.optional(),
|
|
30
|
+
categories: z.array(CategoryId).min(1).optional(),
|
|
31
|
+
currencies: z.array(CurrencyId).min(1).optional(),
|
|
32
|
+
memberships: ids.optional(),
|
|
33
|
+
issuers: ids.optional(),
|
|
34
|
+
// A path in each subject: `welcome_offer`, `rules.amazon`,
|
|
35
|
+
// `credits.instacart.valid_to`. List items are found by their id.
|
|
36
|
+
// Without one, the change is to the whole subject (a new card).
|
|
37
|
+
field: z
|
|
38
|
+
.string()
|
|
39
|
+
.regex(/^[a-z_]+(\.[a-z0-9_-]+)*$/)
|
|
40
|
+
.optional(),
|
|
41
|
+
// The field's value before and after, as written in the data file.
|
|
42
|
+
// `was: null` means it didn't exist; `now: null` means it was removed.
|
|
43
|
+
was: z.unknown().optional(),
|
|
44
|
+
now: z.unknown().optional(),
|
|
45
|
+
summary: z.string().min(1).optional(),
|
|
46
|
+
source: z.url().optional(),
|
|
47
|
+
// When the change reached the data, if before its release's date.
|
|
48
|
+
date: IsoDate.optional(),
|
|
49
|
+
// Issuer changes: when the source check or a person first saw it, and
|
|
50
|
+
// when the issuer says it took effect. Left out when not known.
|
|
51
|
+
detected: IsoDate.optional(),
|
|
52
|
+
effective: IsoDate.optional(),
|
|
53
|
+
})
|
|
54
|
+
.transform(({ card, ...change }) => card === undefined ? change : { ...change, cards: [card] })
|
|
55
|
+
.refine((c) => CHANGE_SUBJECTS.filter((s) => c[s] !== undefined).length === 1, 'a change names one kind of subject: card(s), merchants, categories, currencies, memberships or issuers')
|
|
56
|
+
.refine((c) => c.cause !== 'issuer' ||
|
|
57
|
+
(c.cards?.length === 1 &&
|
|
58
|
+
c.field !== undefined &&
|
|
59
|
+
c.was !== undefined &&
|
|
60
|
+
c.now !== undefined &&
|
|
61
|
+
c.source !== undefined), 'an issuer change needs one card, a field, was, now and source')
|
|
62
|
+
.refine((c) => c.cause === 'issuer' || c.summary !== undefined, "earnrate's own changes need a summary")
|
|
63
|
+
.refine((c) => c.cause === 'issuer' ||
|
|
64
|
+
(c.detected === undefined && c.effective === undefined), 'detected and effective are for issuer changes');
|
|
65
|
+
export const ReleaseSchema = z
|
|
66
|
+
.strictObject({
|
|
67
|
+
version: z.string().regex(DATA_VERSION),
|
|
68
|
+
date: IsoDate,
|
|
69
|
+
changes: z.array(ChangeSchema).min(1),
|
|
70
|
+
})
|
|
71
|
+
.refine((r) => r.date === r.version.slice(0, 10).replaceAll('.', '-'), "a release's date is its version's date");
|
|
72
|
+
// Newest release first, like data/CHANGELOG.md.
|
|
73
|
+
export const ChangesFileSchema = z.array(ReleaseSchema).min(1);
|
package/dist/schema/wallet.js
CHANGED
|
@@ -27,7 +27,8 @@ export const WalletCardSchema = z.strictObject({
|
|
|
27
27
|
.min(1)
|
|
28
28
|
.refine((t) => !labelHasCardNumber(t), CARD_NUMBER_LABEL)
|
|
29
29
|
.optional(),
|
|
30
|
-
// MM-DD
|
|
30
|
+
// MM-DD: for cardmember_year caps and a fee card's renewal check. The
|
|
31
|
+
// opening date stands in for the renewal check when this isn't set.
|
|
31
32
|
anniversary: z
|
|
32
33
|
.string()
|
|
33
34
|
.regex(/^\d{2}-\d{2}$/)
|
|
@@ -46,10 +47,14 @@ export const WalletCardSchema = z.strictObject({
|
|
|
46
47
|
activated: z.array(KebabId).default([]),
|
|
47
48
|
// Keyed by milestone id: every purchase on the card this period.
|
|
48
49
|
milestone_usage: z.record(KebabId, CapUsage).optional(),
|
|
49
|
-
// Merchant credits (the card's `credits`) the cardholder has enrolled in
|
|
50
|
-
//
|
|
50
|
+
// Merchant credits (the card's `credits`) the cardholder has enrolled in
|
|
51
|
+
// or, for ones that need no enrolling, says they use, by credit id; and
|
|
52
|
+
// the dollars of each credit already received this period.
|
|
51
53
|
enrolled: z.array(KebabId).optional(),
|
|
52
54
|
credit_usage: z.record(KebabId, CreditUsage).optional(),
|
|
55
|
+
// Credits the cardholder doesn't use: left out of the pick, the to-do
|
|
56
|
+
// list and the fee math, even if enrolled.
|
|
57
|
+
skipped_credits: z.array(KebabId).optional(),
|
|
53
58
|
// A welcome bonus in progress. Terms default to the card's published
|
|
54
59
|
// offer; set spend, by, points or dollars when yours was different.
|
|
55
60
|
bonus: z
|
|
@@ -70,5 +75,8 @@ export const WalletSchema = z.strictObject({
|
|
|
70
75
|
version: z.number().int().positive().optional(),
|
|
71
76
|
valuations: z.record(CurrencyId, Valuation).default({}),
|
|
72
77
|
memberships: z.array(KebabId).default([]),
|
|
78
|
+
// Issuers whose new cards the person can't or won't get (Chase over
|
|
79
|
+
// 5/24): left out of next-card answers. Never assumed.
|
|
80
|
+
skipped_issuers: z.array(KebabId).optional(),
|
|
73
81
|
cards: z.array(WalletCardSchema).default([]),
|
|
74
82
|
});
|