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.
Files changed (81) hide show
  1. package/README.md +22 -10
  2. package/data/CHANGELOG.md +33 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-business-platinum.yaml +15 -1
  6. package/data/cards/amex-platinum.yaml +1 -1
  7. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  8. package/data/cards/chase-freedom-flex.yaml +9 -12
  9. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  10. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  11. package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
  12. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  13. package/data/cards/chase-ink-business-cash.yaml +5 -1
  14. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  15. package/data/cards/chase-prime-visa.yaml +3 -3
  16. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  17. package/data/cards/chase-united-business.yaml +8 -1
  18. package/data/cards/chase-united-club-business.yaml +10 -1
  19. package/data/cards/chase-united-club.yaml +5 -1
  20. package/data/cards/chase-united-explorer.yaml +5 -1
  21. package/data/cards/chase-united-quest.yaml +5 -1
  22. package/data/cards/citi-aadvantage-business.yaml +5 -0
  23. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  24. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  25. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  26. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  27. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  28. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  29. package/data/cards/citi-custom-cash.yaml +8 -10
  30. package/data/cards/citi-double-cash.yaml +1 -0
  31. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  32. package/data/cards/citi-strata-elite.yaml +2 -0
  33. package/data/cards/citi-strata-premier.yaml +16 -1
  34. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  35. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  36. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  37. package/data/changes.yaml +2840 -0
  38. package/data/merchants/overrides.yaml +3 -3
  39. package/dist/cli/args.js +13 -7
  40. package/dist/cli/offers.js +21 -5
  41. package/dist/cli/run.js +18 -3
  42. package/dist/cli/wallet.js +46 -11
  43. package/dist/data/change-integrity.js +105 -0
  44. package/dist/data/file-store.js +5 -2
  45. package/dist/data/integrity.js +20 -0
  46. package/dist/data/load.js +5 -0
  47. package/dist/data/store.js +4 -1
  48. package/dist/data/wallet-edit.js +21 -5
  49. package/dist/engine/changes.js +124 -0
  50. package/dist/engine/checklist.js +42 -7
  51. package/dist/engine/credits.js +56 -7
  52. package/dist/engine/evaluate.js +21 -1
  53. package/dist/engine/explore.js +52 -15
  54. package/dist/engine/memberships.js +21 -9
  55. package/dist/engine/milestones.js +11 -3
  56. package/dist/engine/offers.js +60 -6
  57. package/dist/engine/periods.js +7 -0
  58. package/dist/engine/recommend.js +21 -2
  59. package/dist/engine/renewal.js +101 -0
  60. package/dist/engine/rule-text.js +21 -13
  61. package/dist/engine/setup.js +12 -8
  62. package/dist/engine/simulate.js +57 -17
  63. package/dist/engine/text.js +110 -5
  64. package/dist/engine/wallet-change.js +34 -15
  65. package/dist/engine/wallet-summary.js +29 -10
  66. package/dist/format/card-text.js +10 -11
  67. package/dist/format/explore.js +12 -8
  68. package/dist/format/offers.js +5 -3
  69. package/dist/format/wallet-yaml.js +3 -0
  70. package/dist/format/wallet.js +8 -1
  71. package/dist/mcp/context.js +11 -3
  72. package/dist/mcp/inputs.js +12 -2
  73. package/dist/mcp/prompts.js +3 -3
  74. package/dist/mcp/schemas.js +47 -0
  75. package/dist/mcp/tools/account.js +1 -1
  76. package/dist/mcp/tools/answers.js +42 -24
  77. package/dist/mcp/tools/wallet.js +33 -9
  78. package/dist/schema/card.js +18 -0
  79. package/dist/schema/changes.js +73 -0
  80. package/dist/schema/wallet.js +11 -3
  81. package/package.json +1 -1
@@ -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 the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee and any membership the card needs that the user doesn't have. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last. Use it when the user asks which new card to get or which has the best sign-up bonus. 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. It doesn't account for what a card would earn on the user's spending.",
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 base = args.memberships
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: z
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
- if (given.some((l) => l.merchant === undefined && l.category === undefined))
288
- return fail('Each spending line needs a merchant or a category.');
289
- const lines = given.length === 0
290
- ? typicalSpendLines(catalog)
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()),
@@ -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 && card && !needsAnniversary(card)
187
- ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
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({ memberships: inputs.AllMemberships });
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) and used this period (creditUsed), 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}`,
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: {
@@ -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);
@@ -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, needed only for cardmember_year caps.
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
- // by credit id, and the dollars of each credit already received this period.
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",