earnrate 0.2.14 → 0.2.15

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 (42) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +12 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  5. package/data/cards/discover-it-cash-back.yaml +1 -1
  6. package/data/cards/venmo-credit-card.yaml +1 -1
  7. package/data/categories.yaml +10 -0
  8. package/dist/cli/import.js +14 -11
  9. package/dist/cli/todo.js +3 -2
  10. package/dist/cli/wallet.js +49 -16
  11. package/dist/engine/cheat-sheet.js +12 -9
  12. package/dist/engine/checklist.js +4 -9
  13. package/dist/engine/credits.js +2 -2
  14. package/dist/engine/evaluate.js +1 -1
  15. package/dist/engine/explore.js +2 -2
  16. package/dist/engine/intro.js +1 -5
  17. package/dist/engine/memberships.js +2 -6
  18. package/dist/engine/offers.js +3 -5
  19. package/dist/engine/periods.js +5 -0
  20. package/dist/engine/profiles.js +2 -1
  21. package/dist/engine/recommend.js +19 -17
  22. package/dist/engine/record.js +1 -1
  23. package/dist/engine/rule-text.js +71 -23
  24. package/dist/engine/setup.js +4 -0
  25. package/dist/engine/text.js +23 -6
  26. package/dist/engine/wallet-change.js +5 -5
  27. package/dist/engine/wallet-summary.js +24 -21
  28. package/dist/format/card-text.js +2 -3
  29. package/dist/format/cards.js +2 -2
  30. package/dist/format/explore.js +2 -2
  31. package/dist/format/wallet-yaml.js +2 -2
  32. package/dist/format/wallet.js +5 -5
  33. package/dist/import/analyze.js +13 -1
  34. package/dist/mcp/context.js +2 -3
  35. package/dist/mcp/inputs.js +3 -3
  36. package/dist/mcp/schemas.js +1 -0
  37. package/dist/mcp/server.js +0 -2
  38. package/dist/mcp/tools/account.js +1 -1
  39. package/dist/mcp/tools/answers.js +8 -4
  40. package/dist/mcp/tools/wallet.js +10 -6
  41. package/dist/schema/card.js +6 -10
  42. package/package.json +2 -1
@@ -80,15 +80,15 @@ const cardFields = {
80
80
  milestoneSpent: z
81
81
  .record(z.string(), z.number().nonnegative())
82
82
  .optional()
83
- .describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
83
+ .describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
84
84
  enrolled: z
85
85
  .array(z.string())
86
86
  .optional()
87
- .describe('Merchant credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
87
+ .describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
88
88
  creditUsed: z
89
89
  .record(z.string(), z.number().nonnegative())
90
90
  .optional()
91
- .describe('Dollars of a merchant credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
91
+ .describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
92
92
  bonus: z
93
93
  .object({
94
94
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -298,6 +298,7 @@ export const CardSearchSchema = z.object({
298
298
  network: CardNetwork,
299
299
  currency: z.string(),
300
300
  annualFee: z.number().nullable(),
301
+ firstYearFee: z.number().nullable(),
301
302
  foreignTransactionFee: z.number().nullable(),
302
303
  business: z.boolean(),
303
304
  onlyAt: z.array(z.string()).nullable(),
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
6
6
  import { registerAccountTools } from "./tools/account.js";
7
7
  import { registerAnswerTools } from "./tools/answers.js";
8
8
  import { registerWalletTools } from "./tools/wallet.js";
9
- // The tools that answer without a saved wallet.
10
9
  export const ANSWER_TOOLS = [
11
10
  'best_card_for_purchase',
12
11
  'best_cards_for',
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
16
15
  'resolve_merchant',
17
16
  'search_cards',
18
17
  ];
19
- // The tools that read or change a saved wallet.
20
18
  export const SAVED_WALLET_TOOLS = [
21
19
  'list_wallet',
22
20
  'record_purchase',
@@ -20,7 +20,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
20
20
  ];
21
21
  server.registerTool('export_wallet', {
22
22
  title: 'Export the wallet',
23
- description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage, bonus progress and yearly spending toward spend bonuses. Never card numbers.',
23
+ 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.',
24
24
  inputSchema: z.object({}),
25
25
  outputSchema: z.object({ yaml: z.string() }),
26
26
  annotations: {
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
34
34
  const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
35
35
  server.registerTool('best_card_for_purchase', {
36
36
  title: 'Best card for a purchase',
37
- description: "Which 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 merchant 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.",
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.",
38
38
  inputSchema: z.object({
39
39
  merchant: Merchant,
40
40
  amount: z
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
166
  .optional()
167
167
  .describe('Include cards already in the wallet.'),
168
168
  cards: NamedCards,
169
+ memberships: NamedMemberships,
169
170
  }),
170
171
  outputSchema: OfferComparisonSchema,
171
172
  annotations: {
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
175
176
  openWorldHint: false,
176
177
  },
177
178
  }, async (args) => {
178
- const wallet = await ctx.cardsFor(args.cards, undefined);
179
+ const wallet = await ctx.cardsFor(args.cards, args.memberships);
179
180
  if (typeof wallet === 'string')
180
181
  return fail(wallet);
181
- const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
182
+ const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
183
+ const base = args.memberships
184
+ ? { ...found, memberships: args.memberships }
185
+ : found;
182
186
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
183
187
  kind: args.kind ?? 'any',
184
188
  noAnnualFee: args.noAnnualFee ?? false,
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
395
  });
392
396
  server.registerTool('search_cards', {
393
397
  title: 'Search cards',
394
- 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.',
398
+ 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 (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
395
399
  inputSchema: z.object({
396
400
  query: z
397
401
  .string()
@@ -63,12 +63,11 @@ function changes(catalog, args) {
63
63
  out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
64
64
  return out;
65
65
  }
66
- // The tools that read or change a saved wallet.
67
66
  export function registerWalletTools(server, deps, ctx, inputs) {
68
67
  const { catalog, store } = deps;
69
68
  server.registerTool('list_wallet', {
70
69
  title: 'List wallet',
71
- 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, merchant 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, merchant 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}`,
70
+ 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}`,
72
71
  outputSchema: WalletSummarySchema,
73
72
  annotations: {
74
73
  title: 'List wallet',
@@ -102,7 +101,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
102
101
  });
103
102
  server.registerTool('record_purchase', {
104
103
  title: 'Record a purchase',
105
- description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend milestones and merchant credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend milestone and no enrolled merchant credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
104
+ description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend bonuses and statement credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend bonus and no enrolled statement credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
106
105
  inputSchema: z.object({
107
106
  cardId: z
108
107
  .string()
@@ -142,6 +141,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
142
141
  if (!result.ok)
143
142
  return fail(`Couldn't record it: ${result.error}`);
144
143
  const { cap: booking, bonus, milestones, credit } = result;
144
+ const capLabel = booking
145
+ ? (walletSummary(catalog, w, purchase.date)
146
+ .cards.find((c) => c.cardId === cardId)
147
+ ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
148
+ : null;
145
149
  const output = booking || bonus || milestones.length > 0 || credit
146
150
  ? {
147
151
  recorded: true,
@@ -149,7 +153,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
149
153
  bonus,
150
154
  milestones,
151
155
  credit,
152
- message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
156
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
153
157
  }
154
158
  : {
155
159
  recorded: false,
@@ -157,7 +161,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
157
161
  bonus: null,
158
162
  milestones: [],
159
163
  credit: null,
160
- message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend milestone or enrolled merchant credit for this purchase, so the wallet wasn't changed.`,
164
+ message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend bonus or enrolled statement credit for this purchase, so the wallet wasn't changed.`,
161
165
  };
162
166
  return {
163
167
  content: [
@@ -191,7 +195,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
191
195
  });
192
196
  server.registerTool('update_card', {
193
197
  title: 'Update a card',
194
- 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 milestones (milestoneSpent), merchant 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}`,
198
+ 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}`,
195
199
  inputSchema: CardChange,
196
200
  outputSchema: WalletChangeSchema,
197
201
  annotations: {
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
157
157
  })),
158
158
  ])),
159
159
  });
160
- // A rate for a stretch after the account opens, counted from the opening
161
- // day: Discover's Cashback Match doubles every rate for the first 365 days,
162
- // Customized Cash adds 3% to its choice category. With `rules`, it changes
163
- // only those rules' rates, inside their caps; past a cap they earn what they
164
- // always do. Without, it changes every rate on the card, past caps included.
160
+ // Counted from the opening day. With `rules`, it changes only those rules'
161
+ // rates, inside their caps; past a cap they earn what they always do.
162
+ // Without, it changes every rate on the card, past caps included.
165
163
  export const IntroRateSchema = z
166
164
  .strictObject({
167
165
  id: KebabId,
@@ -172,11 +170,9 @@ export const IntroRateSchema = z
172
170
  note: z.string().optional(),
173
171
  })
174
172
  .refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
175
- // Merchant statement credits: dollars back for spending at named merchants
176
- // (or a category) up to `amount` each period, like Amex Platinum's $75 a
177
- // quarter at lululemon. They're separate from earn rates: the purchase still
178
- // earns the card's rate on its whole amount, and the credit comes on top.
179
- // `half_year` runs January to June and July to December.
173
+ // Separate from earn rates: the purchase still earns the card's rate on its
174
+ // whole amount, and the credit comes on top. `half_year` runs January to June
175
+ // and July to December.
180
176
  export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
181
177
  export const CreditSchema = z.strictObject({
182
178
  id: KebabId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
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",
@@ -54,6 +54,7 @@
54
54
  "monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
55
55
  "site:build": "vite build site && node scripts/build-pages.ts",
56
56
  "site:dev": "vite site",
57
+ "site:preview": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.preview.jsonc",
57
58
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
58
59
  "test": "vitest run",
59
60
  "test:watch": "vitest",