earnrate 0.2.17 → 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 +9 -3
- package/data/CHANGELOG.md +19 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +3 -6
- package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
- package/data/cards/chase-united-business.yaml +3 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- 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 +2 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/changes.yaml +2840 -0
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +14 -3
- package/dist/cli/wallet.js +28 -3
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +7 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +16 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +18 -3
- package/dist/engine/explore.js +28 -9
- package/dist/engine/offers.js +52 -3
- package/dist/engine/periods.js +7 -0
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +2 -7
- package/dist/engine/setup.js +6 -3
- package/dist/engine/simulate.js +14 -7
- package/dist/engine/text.js +64 -0
- package/dist/engine/wallet-change.js +16 -8
- package/dist/engine/wallet-summary.js +4 -0
- package/dist/format/card-text.js +9 -2
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +6 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +3 -1
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +24 -0
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +28 -8
- package/dist/schema/card.js +7 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +5 -1
- 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(),
|
|
@@ -290,11 +292,24 @@ export const OfferComparisonSchema = z.object({
|
|
|
290
292
|
// if all were used. Not ranked on.
|
|
291
293
|
credits: z.number(),
|
|
292
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(),
|
|
293
307
|
held: z.boolean(),
|
|
294
308
|
lastVerified: z.string(),
|
|
295
309
|
verifiedBy: z.string(),
|
|
296
310
|
})),
|
|
297
311
|
withoutOffer: z.array(z.string()),
|
|
312
|
+
typical: z.boolean(),
|
|
298
313
|
notes: z.array(z.string()),
|
|
299
314
|
});
|
|
300
315
|
export const CardSearchSchema = z.object({
|
|
@@ -320,6 +335,7 @@ export const CardSearchSchema = z.object({
|
|
|
320
335
|
'calendar_year',
|
|
321
336
|
'until_changed',
|
|
322
337
|
]),
|
|
338
|
+
automatic: z.boolean(),
|
|
323
339
|
options: z.array(z.string()),
|
|
324
340
|
default: z.array(z.string()).nullable(),
|
|
325
341
|
})),
|
|
@@ -406,6 +422,7 @@ export const ExplorationSchema = z.object({
|
|
|
406
422
|
choice: z.string(),
|
|
407
423
|
options: z.array(z.string()),
|
|
408
424
|
period: z.string(),
|
|
425
|
+
automatic: z.boolean(),
|
|
409
426
|
})
|
|
410
427
|
.nullable(),
|
|
411
428
|
activation: z.string().nullable(),
|
|
@@ -427,6 +444,13 @@ export const ExplorationSchema = z.object({
|
|
|
427
444
|
excluded: z.array(ExcludedCardSchema),
|
|
428
445
|
assumptions: z.array(z.string()),
|
|
429
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
|
+
})),
|
|
430
454
|
});
|
|
431
455
|
const SimulationSchema = z.object({
|
|
432
456
|
cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
|
|
@@ -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";
|
|
@@ -187,8 +187,11 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
187
187
|
run: async (args) => {
|
|
188
188
|
const what = changes(catalog, args);
|
|
189
189
|
const card = catalog.cards.find((c) => c.id === args.cardId);
|
|
190
|
-
const unused = args.anniversary !== undefined &&
|
|
191
|
-
|
|
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."
|
|
192
195
|
: '';
|
|
193
196
|
return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
|
|
194
197
|
},
|
|
@@ -198,10 +201,27 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
198
201
|
input: RemoveInput,
|
|
199
202
|
run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
|
|
200
203
|
};
|
|
201
|
-
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
|
+
});
|
|
202
208
|
const setMemberships = {
|
|
203
209
|
input: MembershipsInput,
|
|
204
|
-
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.');
|
|
205
225
|
const result = await store.setMemberships(memberships);
|
|
206
226
|
const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
|
|
207
227
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -210,7 +230,7 @@ export function walletHandlers(deps, ctx, inputs) {
|
|
|
210
230
|
const unused = memberships.filter((m) => !used.has(m));
|
|
211
231
|
return ctx.changed(result, `${memberships.length > 0
|
|
212
232
|
? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
|
|
213
|
-
: '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 });
|
|
214
234
|
},
|
|
215
235
|
};
|
|
216
236
|
const PointValuesInput = z.object({
|
|
@@ -259,7 +279,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
259
279
|
const tools = walletHandlers(deps, ctx, inputs);
|
|
260
280
|
server.registerTool('list_wallet', {
|
|
261
281
|
title: 'List wallet',
|
|
262
|
-
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}`,
|
|
263
283
|
outputSchema: WalletSummarySchema,
|
|
264
284
|
annotations: {
|
|
265
285
|
title: 'List wallet',
|
|
@@ -319,7 +339,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
319
339
|
}, (args) => tools.remove_card.run(args));
|
|
320
340
|
server.registerTool('set_memberships', {
|
|
321
341
|
title: 'Set memberships',
|
|
322
|
-
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}`,
|
|
323
343
|
inputSchema: tools.set_memberships.input,
|
|
324
344
|
outputSchema: WalletChangeSchema,
|
|
325
345
|
annotations: {
|
package/dist/schema/card.js
CHANGED
|
@@ -129,6 +129,10 @@ export const ChoiceSchema = z.strictObject({
|
|
|
129
129
|
// `until_changed`: the pick carries over until the cardholder changes it,
|
|
130
130
|
// so a pick made in an earlier period is still current.
|
|
131
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(),
|
|
132
136
|
// Option ids that apply when the wallet hasn't picked, e.g. Bilt's dining.
|
|
133
137
|
default: z.array(KebabId).min(1).optional(),
|
|
134
138
|
// Option id → what that option covers: a list of categories; or, when the
|
|
@@ -254,6 +258,9 @@ export const CardSchema = z
|
|
|
254
258
|
rule: z.string().min(1),
|
|
255
259
|
url: z.url(),
|
|
256
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(),
|
|
257
264
|
}))
|
|
258
265
|
.default([]),
|
|
259
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}$/)
|
|
@@ -74,5 +75,8 @@ export const WalletSchema = z.strictObject({
|
|
|
74
75
|
version: z.number().int().positive().optional(),
|
|
75
76
|
valuations: z.record(CurrencyId, Valuation).default({}),
|
|
76
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(),
|
|
77
81
|
cards: z.array(WalletCardSchema).default([]),
|
|
78
82
|
});
|