earnrate 0.1.1 → 0.1.3

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.
@@ -103,6 +103,7 @@ export const WalletSummarySchema = z.object({
103
103
  lastVerified: z.string(),
104
104
  verifiedBy: z.string(),
105
105
  dataIsStale: z.boolean(),
106
+ rotationGap: z.string().nullable(),
106
107
  caps: z.array(z.object({
107
108
  key: z.string(),
108
109
  rules: z.array(z.string()),
@@ -172,3 +173,118 @@ export const OfferComparisonSchema = z.object({
172
173
  withoutOffer: z.array(z.string()),
173
174
  notes: z.array(z.string()),
174
175
  });
176
+ export const CardSearchSchema = z.object({
177
+ exact: z.boolean(),
178
+ cards: z.array(z.object({
179
+ id: z.string(),
180
+ name: z.string(),
181
+ issuer: z.string(),
182
+ network: CardNetwork,
183
+ currency: z.string(),
184
+ annualFee: z.number().nullable(),
185
+ business: z.boolean(),
186
+ onlyAt: z.array(z.string()).nullable(),
187
+ held: z.boolean(),
188
+ choices: z.array(z.object({
189
+ id: z.string(),
190
+ pick: z.number(),
191
+ period: z.enum([
192
+ 'month',
193
+ 'quarter',
194
+ 'calendar_year',
195
+ 'until_changed',
196
+ ]),
197
+ options: z.array(z.string()),
198
+ default: z.array(z.string()).nullable(),
199
+ })),
200
+ memberships: z.array(z.string()),
201
+ activation: z.array(z.string()),
202
+ needsAnniversary: z.boolean(),
203
+ })),
204
+ });
205
+ export const WalletChangeSchema = z.object({
206
+ message: z.string(),
207
+ created: z.boolean(),
208
+ cardIds: z.array(z.string()),
209
+ memberships: z.array(z.string()),
210
+ notes: z.array(z.object({
211
+ kind: z.enum([
212
+ 'pick',
213
+ 'membership',
214
+ 'activation',
215
+ 'anniversary',
216
+ 'rotation',
217
+ ]),
218
+ text: z.string(),
219
+ })),
220
+ });
221
+ const ExcludedCardSchema = z.object({
222
+ cardId: z.string(),
223
+ cardName: z.string(),
224
+ checkoutLabel: z.string().nullable(),
225
+ network: Network,
226
+ reason: z.string(),
227
+ });
228
+ const Picks = z.record(z.string(), z.array(z.string()));
229
+ export const ExplorationSchema = z.object({
230
+ merchant: MerchantResolutionSchema,
231
+ ranked: z.array(RankedCardSchema.extend({
232
+ issuer: z.string(),
233
+ network: CardNetwork,
234
+ annualFee: z.number().nullable(),
235
+ firstYearFee: z.number().nullable(),
236
+ picks: Picks,
237
+ requirements: z.object({
238
+ pick: z
239
+ .object({
240
+ choice: z.string(),
241
+ options: z.array(z.string()),
242
+ period: z.string(),
243
+ })
244
+ .nullable(),
245
+ activation: z.string().nullable(),
246
+ membership: z.string().nullable(),
247
+ cap: z
248
+ .object({
249
+ amount: z.number(),
250
+ period: z.string(),
251
+ thenRate: z.number(),
252
+ currency: z.string(),
253
+ })
254
+ .nullable(),
255
+ }),
256
+ conditions: z.array(z.string()),
257
+ evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
258
+ withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
259
+ })),
260
+ excluded: z.array(ExcludedCardSchema),
261
+ assumptions: z.array(z.string()),
262
+ warnings: z.array(z.string()),
263
+ });
264
+ const SimulationSchema = z.object({
265
+ cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
266
+ spend: z.number(),
267
+ rewards: z.number(),
268
+ annualFees: z.number(),
269
+ net: z.number(),
270
+ lines: z.array(z.object({
271
+ label: z.string(),
272
+ spend: z.number(),
273
+ earned: z.number(),
274
+ returnPct: z.number(),
275
+ cards: z.array(z.string()),
276
+ })),
277
+ notes: z.array(z.string()),
278
+ });
279
+ export const SpendingSimulationSchema = z.object({
280
+ singles: z.array(SimulationSchema),
281
+ pairs: z.array(SimulationSchema.extend({ alternatives: z.array(z.string()) })),
282
+ current: SimulationSchema.nullable(),
283
+ additions: z.array(z.object({
284
+ cardId: z.string(),
285
+ picks: Picks,
286
+ extraRewards: z.number(),
287
+ annualFee: z.number(),
288
+ gain: z.number(),
289
+ })),
290
+ });
@@ -1,19 +1,26 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { MCP_SERVER_NAME } from "../brand.js";
4
+ import { formatCardInfo, formatNotes } from "../cli/cards.js";
4
5
  import { formatRecommendation } from "../cli/format.js";
6
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
5
7
  import { formatOffers } from "../cli/offers.js";
6
8
  import { walletProblems } from "../data/integrity.js";
7
9
  import { loadWallet } from "../data/load.js";
8
10
  import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
11
+ import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
9
12
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
13
+ import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
10
14
  import { compareOffers } from "../engine/offers.js";
15
+ import { VALUATION_PROFILES } from "../engine/profiles.js";
11
16
  import { capBooking } from "../engine/record.js";
12
17
  import { recommend } from "../engine/recommend.js";
18
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
19
+ import { knownMemberships, searchCards } from "../engine/setup.js";
13
20
  import { walletSummary } from "../engine/wallet-summary.js";
14
21
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
15
22
  import { IsoDate } from "../schema/common.js";
16
- import { MerchantResolutionSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletSummarySchema, } from "./schemas.js";
23
+ import { CardSearchSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
17
24
  const Merchant = z
18
25
  .string()
19
26
  .min(1)
@@ -23,13 +30,16 @@ const ChannelInput = z
23
30
  .optional()
24
31
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
25
32
  const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
33
+ const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. best_cards_for and simulate_spending compare every card earnrate knows and need no wallet, for questions like "what's the best grocery card?" or "which cards fit my spending?". Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
34
+
35
+ If a tool says there's no wallet yet, ask the user which cards they have, find each with search_cards, and add it with add_card. add_card and update_card reply with what a card still needs, such as category picks, memberships like Amazon Prime, or activated bonus categories. Ask the user about those and set them with update_card or set_memberships.`;
26
36
  const fail = (text) => ({
27
37
  content: [{ type: 'text', text }],
28
38
  isError: true,
29
39
  });
30
40
  const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
31
41
  export function createEarnrateServer(deps) {
32
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version });
42
+ const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
33
43
  const { catalog } = deps;
34
44
  // Read on every call, so edits to the wallet file apply without a restart.
35
45
  async function wallet() {
@@ -38,6 +48,9 @@ export function createEarnrateServer(deps) {
38
48
  loaded = await loadWallet(deps.walletFile);
39
49
  }
40
50
  catch (e) {
51
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
52
+ return `There's no wallet yet (${deps.walletFile}). Ask the user which credit cards they have, find each one with search_cards, and add it with add_card.`;
53
+ }
41
54
  return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
42
55
  }
43
56
  const problems = walletProblems(loaded, catalog);
@@ -145,13 +158,16 @@ export function createEarnrateServer(deps) {
145
158
  ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
146
159
  : '';
147
160
  const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
161
+ const rotation = c.rotationGap === null
162
+ ? ''
163
+ : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
148
164
  const b = c.bonus;
149
165
  const bonus = b?.state === 'open' && b.spend !== null
150
166
  ? `; welcome bonus ${dollars(b.spent)} of ${dollars(b.spend)} spent, due ${b.deadline ?? ''}`
151
167
  : b
152
168
  ? `; welcome bonus ${b.state}`
153
169
  : '';
154
- return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${onlyAt}${bonus}`;
170
+ return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
155
171
  });
156
172
  return {
157
173
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -199,6 +215,153 @@ export function createEarnrateServer(deps) {
199
215
  structuredContent: comparison,
200
216
  };
201
217
  });
218
+ const CatalogCategory = z
219
+ .enum(ids)
220
+ .describe(`A spending category: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
221
+ const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
222
+ const membershipIds = knownMemberships(catalog);
223
+ const CatalogFilters = {
224
+ kind: z
225
+ .enum(['any', 'cash', 'points'])
226
+ .optional()
227
+ .describe('cash: cash back cards only. points: points and miles cards only.'),
228
+ noAnnualFee: z
229
+ .boolean()
230
+ .optional()
231
+ .describe('Only cards with no annual fee.'),
232
+ business: z
233
+ .boolean()
234
+ .optional()
235
+ .describe('Business cards instead of personal ones.'),
236
+ issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
237
+ memberships: z
238
+ .array(z.enum(membershipIds))
239
+ .optional()
240
+ .describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
241
+ valuation: z
242
+ .enum(['flat', 'points-heavy'])
243
+ .optional()
244
+ .describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
245
+ };
246
+ const settings = (args) => ({
247
+ filters: {
248
+ ...ALL_PERSONAL_CARDS,
249
+ kind: args.kind ?? 'any',
250
+ noAnnualFee: args.noAnnualFee ?? false,
251
+ business: args.business ?? false,
252
+ issuer: args.issuer ?? null,
253
+ },
254
+ memberships: args.memberships ?? [],
255
+ valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
256
+ });
257
+ server.registerTool('best_cards_for', {
258
+ title: 'Best cards for a purchase',
259
+ description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
260
+ inputSchema: z.object({
261
+ merchant: Merchant.optional(),
262
+ category: CatalogCategory.optional(),
263
+ amount: z
264
+ .number()
265
+ .positive()
266
+ .optional()
267
+ .describe('Purchase amount in US dollars, if known.'),
268
+ channel: ChannelInput,
269
+ ...CatalogFilters,
270
+ }),
271
+ outputSchema: ExplorationSchema,
272
+ annotations: { readOnlyHint: true, openWorldHint: false },
273
+ }, (args) => {
274
+ if (args.merchant === undefined && args.category === undefined)
275
+ return fail('Give a merchant or a category.');
276
+ const result = exploreCards(catalog, {
277
+ merchant: args.merchant ?? null,
278
+ category: args.merchant === undefined ? (args.category ?? null) : null,
279
+ amount: args.amount ?? null,
280
+ channel: args.channel ?? null,
281
+ date: deps.today(),
282
+ }, settings(args));
283
+ const trimmed = { ...result, ranked: result.ranked.slice(0, 10) };
284
+ return {
285
+ content: [{ type: 'text', text: formatExploration(trimmed, true) }],
286
+ structuredContent: trimmed,
287
+ };
288
+ });
289
+ server.registerTool('simulate_spending', {
290
+ title: 'Simulate a year of spending',
291
+ description: 'Spends the user\'s monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. With compareToWallet, it starts from the user\'s wallet and ranks the cards that would add the most to it. Use it for "which card fits my spending?" or "is it worth adding a card?". Ask for rough monthly amounts if the user hasn\'t given them. It leaves out welcome bonuses.',
292
+ inputSchema: z.object({
293
+ spending: z
294
+ .array(z.object({
295
+ merchant: Merchant.optional(),
296
+ category: CatalogCategory.optional(),
297
+ monthly: z.number().positive().describe('US dollars a month.'),
298
+ }))
299
+ .min(1)
300
+ .describe('Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco).'),
301
+ compareToWallet: z
302
+ .boolean()
303
+ .optional()
304
+ .describe("Rank the cards that would add the most to the user's wallet."),
305
+ ...CatalogFilters,
306
+ }),
307
+ outputSchema: SpendingSimulationSchema,
308
+ annotations: { readOnlyHint: true, openWorldHint: false },
309
+ }, async (args) => {
310
+ if (args.spending.some((l) => l.merchant === undefined && l.category === undefined))
311
+ return fail('Each spending line needs a merchant or a category.');
312
+ const lines = resolveSpendLines(catalog, args.spending.map((l) => ({
313
+ merchant: l.merchant ?? null,
314
+ category: l.merchant === undefined ? (l.category ?? null) : null,
315
+ monthly: l.monthly,
316
+ })));
317
+ const unknown = lines.filter((l) => l.merchant.source === 'none');
318
+ if (unknown.length > 0)
319
+ return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
320
+ const options = { ...settings(args), start: deps.today() };
321
+ if (args.compareToWallet) {
322
+ const w = await wallet();
323
+ if (typeof w === 'string')
324
+ return fail(w);
325
+ const valued = args.valuation
326
+ ? {
327
+ ...w,
328
+ valuations: {
329
+ ...w.valuations,
330
+ ...VALUATION_PROFILES[args.valuation],
331
+ },
332
+ }
333
+ : w;
334
+ const result = simulateAdditions(catalog, valued, lines, options);
335
+ return {
336
+ content: [
337
+ { type: 'text', text: formatAdditions(result, catalog, false) },
338
+ ],
339
+ structuredContent: {
340
+ singles: [],
341
+ pairs: [],
342
+ current: result.current,
343
+ additions: result.additions
344
+ .slice(0, 10)
345
+ .map(({ simulation: _, ...a }) => a),
346
+ },
347
+ };
348
+ }
349
+ const result = simulateCatalog(catalog, lines, options);
350
+ return {
351
+ content: [
352
+ {
353
+ type: 'text',
354
+ text: formatCatalogSimulation(result, catalog, false),
355
+ },
356
+ ],
357
+ structuredContent: {
358
+ singles: result.singles.slice(0, 10),
359
+ pairs: result.pairs.slice(0, 5),
360
+ current: null,
361
+ additions: [],
362
+ },
363
+ };
364
+ });
202
365
  server.registerTool('record_purchase', {
203
366
  title: 'Record a purchase',
204
367
  description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's wallet file. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
@@ -304,5 +467,148 @@ export function createEarnrateServer(deps) {
304
467
  structuredContent: output,
305
468
  };
306
469
  }));
470
+ const CardId = z
471
+ .string()
472
+ .min(1)
473
+ .describe('The card id from search_cards, e.g. chase-sapphire-preferred.');
474
+ const cardFields = {
475
+ checkoutLabel: z
476
+ .string()
477
+ .min(1)
478
+ .optional()
479
+ .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
480
+ picks: z
481
+ .record(z.string(), z.array(z.string()).min(1))
482
+ .optional()
483
+ .describe('For cards where the cardholder picks bonus categories: choice id → option ids, from search_cards, e.g. {"five-percent": ["tv-internet-streaming", "fast-food"]}. Saved for the current period. Ask the user; don\'t guess.'),
484
+ anniversary: z
485
+ .string()
486
+ .regex(/^\d{2}-\d{2}$/)
487
+ .optional()
488
+ .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
489
+ activated: z
490
+ .array(z.string())
491
+ .optional()
492
+ .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
493
+ };
494
+ // A card change reports what that card still needs; other changes don't.
495
+ function changed(result, message, cardChange = true) {
496
+ if (!result.ok)
497
+ return fail(result.error);
498
+ const output = {
499
+ message,
500
+ created: result.created,
501
+ cardIds: result.wallet.cards.map((c) => c.id),
502
+ memberships: result.wallet.memberships,
503
+ notes: result.notes,
504
+ };
505
+ const text = [
506
+ message,
507
+ `Wallet: ${output.cardIds.join(', ')}.`,
508
+ ...(cardChange ? [formatNotes(result.notes)] : []),
509
+ ].join('\n');
510
+ return {
511
+ content: [{ type: 'text', text }],
512
+ structuredContent: output,
513
+ };
514
+ }
515
+ const cardInput = (args) => ({
516
+ id: args.cardId,
517
+ ...(args.checkoutLabel === undefined
518
+ ? {}
519
+ : { checkoutLabel: args.checkoutLabel }),
520
+ ...(args.picks === undefined ? {} : { picks: args.picks }),
521
+ ...(args.anniversary === undefined
522
+ ? {}
523
+ : { anniversary: args.anniversary }),
524
+ ...(args.activated === undefined ? {} : { activated: args.activated }),
525
+ });
526
+ const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
527
+ server.registerTool('search_cards', {
528
+ title: 'Search cards',
529
+ description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
530
+ inputSchema: z.object({
531
+ query: z
532
+ .string()
533
+ .optional()
534
+ .describe('Card name or issuer, as the user said it.'),
535
+ }),
536
+ outputSchema: CardSearchSchema,
537
+ annotations: { readOnlyHint: true, openWorldHint: false },
538
+ }, async ({ query }) => {
539
+ const held = await loadWallet(deps.walletFile).catch(() => null);
540
+ const result = searchCards(catalog, query ?? '', held, deps.today());
541
+ const header = result.exact
542
+ ? ''
543
+ : result.cards.length > 0
544
+ ? 'No card matched every word. Closest:\n'
545
+ : `No card matched "${query ?? ''}". earnrate covers ${String(catalog.cards.length)} US cards; list them all by leaving the query out.`;
546
+ return {
547
+ content: [
548
+ {
549
+ type: 'text',
550
+ text: header + result.cards.map(formatCardInfo).join('\n'),
551
+ },
552
+ ],
553
+ structuredContent: result,
554
+ };
555
+ });
556
+ server.registerTool('add_card', {
557
+ title: 'Add a card',
558
+ description: "Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers.",
559
+ inputSchema: z.object({ cardId: CardId, ...cardFields }),
560
+ outputSchema: WalletChangeSchema,
561
+ annotations: {
562
+ readOnlyHint: false,
563
+ destructiveHint: false,
564
+ idempotentHint: false,
565
+ openWorldHint: false,
566
+ },
567
+ }, (args) => withWalletLock(deps.walletFile, async () => {
568
+ const result = await addCard(deps.walletFile, catalog, cardInput(args), deps.today());
569
+ return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? `, starting a new wallet at ${deps.walletFile}` : ''}.`);
570
+ }));
571
+ server.registerTool('update_card', {
572
+ title: 'Update a card',
573
+ description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
574
+ inputSchema: z.object({ cardId: CardId, ...cardFields }),
575
+ outputSchema: WalletChangeSchema,
576
+ annotations: {
577
+ readOnlyHint: false,
578
+ destructiveHint: false,
579
+ idempotentHint: true,
580
+ openWorldHint: false,
581
+ },
582
+ }, (args) => withWalletLock(deps.walletFile, async () => changed(await updateCard(deps.walletFile, catalog, cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
583
+ server.registerTool('remove_card', {
584
+ title: 'Remove a card',
585
+ description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
586
+ inputSchema: z.object({ cardId: CardId }),
587
+ outputSchema: WalletChangeSchema,
588
+ annotations: {
589
+ readOnlyHint: false,
590
+ destructiveHint: true,
591
+ idempotentHint: true,
592
+ openWorldHint: false,
593
+ },
594
+ }, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
595
+ server.registerTool('set_memberships', {
596
+ title: 'Set memberships',
597
+ 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. Replaces the whole list, so include the ones to keep.",
598
+ inputSchema: z.object({
599
+ memberships: z
600
+ .array(z.enum(membershipIds))
601
+ .describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`),
602
+ }),
603
+ outputSchema: WalletChangeSchema,
604
+ annotations: {
605
+ readOnlyHint: false,
606
+ destructiveHint: false,
607
+ idempotentHint: true,
608
+ openWorldHint: false,
609
+ },
610
+ }, (args) => withWalletLock(deps.walletFile, async () => changed(await setMemberships(deps.walletFile, catalog, args.memberships), args.memberships.length > 0
611
+ ? `Memberships set to ${args.memberships.join(', ')}.`
612
+ : 'Memberships cleared.', false)));
307
613
  return server;
308
614
  }
@@ -12,7 +12,13 @@ export const CapSchema = z.strictObject({
12
12
  period: CapPeriod,
13
13
  then_rate: z.number().nonnegative(),
14
14
  });
15
- export const SharedCapSchema = CapSchema.extend({ id: KebabId });
15
+ export const SharedCapSchema = CapSchema.extend({
16
+ id: KebabId,
17
+ // Rotating categories (Freedom Flex): every rule on this cap covers one
18
+ // dated period, and a period with no rule means the issuer's categories
19
+ // aren't in the data yet, not that there's no bonus.
20
+ rotating: z.literal(true).optional(),
21
+ });
16
22
  const hasAny = (m) => Object.values(m).some((v) => v === true || (v !== undefined && v.length > 0));
17
23
  // Kinds combine with AND, values within a kind with OR: `{ merchants:
18
24
  // [lyft], categories: [transit] }` is a Lyft purchase that codes as transit.
@@ -127,6 +133,8 @@ export const CardSchema = z
127
133
  source_urls: z.array(z.url()).min(1),
128
134
  base_rate: z.number().nonnegative(),
129
135
  business: z.boolean().default(false),
136
+ // When the issuer stopped taking applications; cardholders keep earning.
137
+ applications_closed: IsoDate.optional(),
130
138
  annual_fee: z.number().nonnegative().optional(), // dollars
131
139
  // When the first year differs, usually a waived fee.
132
140
  first_year_fee: z.number().nonnegative().optional(),
@@ -6,6 +6,8 @@ export const CategorySchema = z.strictObject({
6
6
  // Typical MCCs, as hints for explanations and the LLM fallback. Matching is
7
7
  // by category, not by these.
8
8
  mccs: z.array(Mcc).default([]),
9
+ // Everyday words people search by ("groceries"), besides the name.
10
+ aliases: z.array(z.string().min(1)).default([]),
9
11
  note: z.string().optional(),
10
12
  });
11
13
  export const CategoriesFileSchema = z.array(CategorySchema).min(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Picks the best credit card from your wallet for a merchant, and explains why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -21,7 +21,7 @@
21
21
  "mcpName": "io.github.cwasley/earnrate",
22
22
  "type": "module",
23
23
  "engines": {
24
- "node": ">=24"
24
+ "node": ">=22"
25
25
  },
26
26
  "bin": {
27
27
  "earnrate": "./dist/cli/bin.js",
@@ -40,11 +40,13 @@
40
40
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
41
41
  "prepack": "yarn build",
42
42
  "smoke:pack": "node scripts/smoke-pack.ts",
43
- "site:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy",
43
+ "site:build": "vite build site",
44
+ "site:dev": "vite site",
45
+ "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
44
46
  "test": "vitest run",
45
47
  "test:watch": "vitest",
46
48
  "coverage": "vitest run --coverage --coverage.include=src/**",
47
- "typecheck": "tsc",
49
+ "typecheck": "tsc && tsc -p site",
48
50
  "lint": "eslint",
49
51
  "format": "prettier --write .",
50
52
  "format:check": "prettier --check .",
@@ -64,6 +66,7 @@
64
66
  "eslint": "^10.13.0",
65
67
  "eslint-config-prettier": "^10.1.8",
66
68
  "playwright": "1.64.1",
69
+ "preact": "^10.29.8",
67
70
  "prettier": "^3.9.10",
68
71
  "typescript": "~6.0.3",
69
72
  "typescript-eslint": "^8.72.0",