earnrate 0.2.2 → 0.2.4

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 (49) hide show
  1. package/README.md +13 -12
  2. package/data/CHANGELOG.md +35 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/chase-freedom-flex.yaml +1 -1
  6. package/data/cards/chase-southwest-plus.yaml +4 -0
  7. package/data/cards/chase-southwest-premier.yaml +4 -0
  8. package/data/cards/chase-southwest-priority.yaml +4 -0
  9. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  10. package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
  11. package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
  12. package/data/merchants/overrides.yaml +12 -1
  13. package/data/merchants/seed.yaml +0 -6
  14. package/dist/cli/cheatsheet.js +1 -1
  15. package/dist/cli/explore.js +1 -1
  16. package/dist/cli/import.js +4 -3
  17. package/dist/cli/run.js +1 -1
  18. package/dist/data/file-store.js +3 -1
  19. package/dist/data/integrity.js +10 -0
  20. package/dist/data/store.js +9 -1
  21. package/dist/data/wallet-edit.js +8 -2
  22. package/dist/engine/cheat-sheet.js +44 -2
  23. package/dist/engine/explore.js +14 -5
  24. package/dist/engine/memberships.js +45 -0
  25. package/dist/engine/offers.js +22 -2
  26. package/dist/engine/recommend.js +91 -31
  27. package/dist/engine/setup.js +23 -4
  28. package/dist/engine/simulate.js +32 -10
  29. package/dist/engine/text.js +51 -0
  30. package/dist/engine/wallet-change.js +27 -7
  31. package/dist/engine/wallet-summary.js +133 -3
  32. package/dist/format/card-text.js +7 -13
  33. package/dist/format/cards.js +7 -8
  34. package/dist/format/explore.js +17 -8
  35. package/dist/format/offers.js +5 -9
  36. package/dist/format/recommendation.js +11 -5
  37. package/dist/format/wallet-yaml.js +18 -3
  38. package/dist/format/wallet.js +21 -18
  39. package/dist/mcp/context.js +7 -3
  40. package/dist/mcp/inputs.js +2 -2
  41. package/dist/mcp/schemas.js +18 -0
  42. package/dist/mcp/server.js +3 -3
  43. package/dist/mcp/tools/account.js +55 -7
  44. package/dist/mcp/tools/answers.js +24 -7
  45. package/dist/mcp/tools/wallet.js +49 -7
  46. package/dist/schema/card.js +14 -0
  47. package/dist/schema/wallet.js +4 -0
  48. package/package.json +1 -1
  49. package/wallet.example.yaml +1 -0
@@ -120,11 +120,11 @@ export function makeInputs(catalog) {
120
120
  .array(NamedCard)
121
121
  .optional()
122
122
  .describe("Cards the user named in this conversation, to answer for just those cards without saving anything. Leave out to use the user's saved wallet if they have one; with neither, the answer covers every card earnrate knows.");
123
- const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime, when they're named in the conversation rather than saved.");
123
+ const NamedMemberships = Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco, when they're named in the conversation rather than saved.");
124
124
  const CatalogFilters = {
125
125
  ...OfferFilters,
126
126
  issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
127
- memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
127
+ memberships: Memberships.optional().describe("Memberships the user has, such as amazon-prime or costco. Rates that need one they don't have are listed separately; a card that needs one (Costco Anywhere) says so."),
128
128
  valuation: z
129
129
  .enum(['flat', 'points-heavy'])
130
130
  .optional()
@@ -106,6 +106,7 @@ export const WalletSummarySchema = z.object({
106
106
  rotationGap: z.string().nullable(),
107
107
  caps: z.array(z.object({
108
108
  key: z.string(),
109
+ label: z.string(),
109
110
  rules: z.array(z.string()),
110
111
  amount: z.number(),
111
112
  period: CapPeriod,
@@ -117,10 +118,14 @@ export const WalletSummarySchema = z.object({
117
118
  .object({
118
119
  period: z.string(),
119
120
  current: z.boolean(),
121
+ until: z.string().nullable(),
120
122
  picks: z.record(z.string(), z.array(z.string())),
123
+ pickNames: z.record(z.string(), z.array(z.string())),
121
124
  })
122
125
  .nullable(),
126
+ rates: z.array(z.string()),
123
127
  activated: z.array(z.string()),
128
+ toActivate: z.array(z.string()),
124
129
  anniversary: z.string().nullable(),
125
130
  })),
126
131
  });
@@ -165,6 +170,8 @@ export const OfferComparisonSchema = z.object({
165
170
  note: z.string().nullable(),
166
171
  annualFee: z.number().nullable(),
167
172
  firstYearFee: z.number().nullable(),
173
+ membershipFee: z.number(),
174
+ ineligible: z.string().nullable(),
168
175
  bonusValue: z.number(),
169
176
  firstYearValue: z.number(),
170
177
  held: z.boolean(),
@@ -199,6 +206,9 @@ export const CardSearchSchema = z.object({
199
206
  default: z.array(z.string()).nullable(),
200
207
  })),
201
208
  memberships: z.array(z.string()),
209
+ requiresMembership: z
210
+ .object({ id: z.string(), name: z.string(), annualFee: z.number() })
211
+ .nullable(),
202
212
  activation: z.array(z.string()),
203
213
  needsAnniversary: z.boolean(),
204
214
  })),
@@ -267,6 +277,13 @@ const SimulationSchema = z.object({
267
277
  spend: z.number(),
268
278
  rewards: z.number(),
269
279
  annualFees: z.number(),
280
+ memberships: z.array(z.object({
281
+ id: z.string(),
282
+ name: z.string(),
283
+ annualFee: z.number(),
284
+ cardIds: z.array(z.string()),
285
+ })),
286
+ membershipFees: z.number(),
270
287
  net: z.number(),
271
288
  lines: z.array(z.object({
272
289
  label: z.string(),
@@ -287,6 +304,7 @@ export const SpendingSimulationSchema = z.object({
287
304
  picks: Picks,
288
305
  extraRewards: z.number(),
289
306
  annualFee: z.number(),
307
+ membershipFee: z.number(),
290
308
  gain: z.number(),
291
309
  })),
292
310
  });
@@ -15,8 +15,7 @@ export const ANSWER_TOOLS = [
15
15
  'resolve_merchant',
16
16
  'search_cards',
17
17
  ];
18
- // The tools that read or change a saved wallet. The hosted server asks for
19
- // sign-in before running one.
18
+ // The tools that read or change a saved wallet.
20
19
  export const SAVED_WALLET_TOOLS = [
21
20
  'list_wallet',
22
21
  'record_purchase',
@@ -27,10 +26,11 @@ export const SAVED_WALLET_TOOLS = [
27
26
  'export_wallet',
28
27
  'import_wallet',
29
28
  'delete_wallet',
29
+ 'switch_wallet',
30
30
  ];
31
31
  const SAVING = {
32
32
  file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
33
- account: "Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
33
+ account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
34
34
  };
35
35
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
36
36
 
@@ -46,16 +46,36 @@ export function registerAccountTools(server, catalog, store, ctx) {
46
46
  const parsed = parseWalletYaml(yaml, catalog);
47
47
  if (typeof parsed === 'string')
48
48
  return fail(parsed);
49
- const current = await store.load().catch(() => null);
49
+ let current;
50
+ try {
51
+ current = await store.load();
52
+ }
53
+ catch (e) {
54
+ if (replace !== true)
55
+ return fail(`Couldn't read the saved wallet to check what it holds (${e instanceof Error ? e.message : String(e)}). Ask the user, then call again with replace: true to overwrite it.`);
56
+ current = null;
57
+ }
50
58
  if (current !== null && current.cards.length > 0 && replace !== true)
51
59
  return fail(`This would replace the ${String(current.cards.length)} ${current.cards.length === 1 ? 'card' : 'cards'} already saved (${current.cards.map((c) => c.id).join(', ')}). Ask the user, then call again with replace: true.`);
52
- const count = parsed.cards.length;
53
- return ctx.changed(await store.replace(parsed), `Imported a wallet with ${String(count)} ${count === 1 ? 'card' : 'cards'}.`, { notes: false });
60
+ const cards = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
61
+ const before = current?.cards.length ?? 0;
62
+ return ctx.changed(await store.replace(parsed), before > 0
63
+ ? `Replaced the ${cards(before)} that were saved with the ${cards(parsed.cards.length)} imported.`
64
+ : `Imported a wallet with ${cards(parsed.cards.length)}.`, { notes: false });
54
65
  });
55
66
  server.registerTool('delete_wallet', {
56
67
  title: 'Delete the wallet',
57
- description: 'Deletes the saved wallet now and for good, and signs out every app using it. Only when the user asks to delete it; confirm first, and offer export_wallet to keep a copy.',
58
- inputSchema: z.object({}),
68
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm and cards only after the user confirms. It refuses if the wallet no longer holds that many cards, so a retried call can't delete a different wallet.",
69
+ inputSchema: z.object({
70
+ confirm: z
71
+ .literal(true)
72
+ .describe('true, only after the user confirms the deletion.'),
73
+ cards: z
74
+ .number()
75
+ .int()
76
+ .nonnegative()
77
+ .describe('How many cards the wallet held when the user confirmed, from list_wallet: 0 for an empty wallet.'),
78
+ }),
59
79
  outputSchema: z.object({ deleted: z.boolean() }),
60
80
  annotations: {
61
81
  readOnlyHint: false,
@@ -63,16 +83,44 @@ export function registerAccountTools(server, catalog, store, ctx) {
63
83
  idempotentHint: true,
64
84
  openWorldHint: false,
65
85
  },
66
- }, async () => {
86
+ }, async ({ cards }) => {
87
+ // An unreadable wallet can still be deleted: the user confirmed it.
88
+ const current = await store.load().catch(() => undefined);
89
+ const held = current === undefined ? cards : (current?.cards.length ?? 0);
90
+ if (held !== cards)
91
+ return fail(`This saved wallet holds ${String(held)} ${held === 1 ? 'card' : 'cards'}, not ${String(cards)}, so it isn't the wallet the user confirmed (it may have been deleted and a new one connected). Show it with list_wallet and confirm again.`);
67
92
  await store.erase();
68
93
  return {
69
94
  content: [
70
95
  {
71
96
  type: 'text',
72
- text: 'Deleted the saved wallet. Answers now cover every card, or the cards named in the conversation.',
97
+ text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
73
98
  },
74
99
  ],
75
100
  structuredContent: { deleted: true },
76
101
  };
77
102
  });
103
+ server.registerTool('switch_wallet', {
104
+ title: 'Switch wallets',
105
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
106
+ inputSchema: z.object({}),
107
+ outputSchema: z.object({ disconnected: z.boolean() }),
108
+ annotations: {
109
+ readOnlyHint: false,
110
+ destructiveHint: false,
111
+ idempotentHint: true,
112
+ openWorldHint: false,
113
+ },
114
+ }, async () => {
115
+ await store.disconnect();
116
+ return {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
121
+ },
122
+ ],
123
+ structuredContent: { disconnected: true },
124
+ };
125
+ });
78
126
  }
@@ -6,7 +6,7 @@ import { withValuationProfile } from "../../engine/profiles.js";
6
6
  import { recommend } from "../../engine/recommend.js";
7
7
  import { searchCards } from "../../engine/setup.js";
8
8
  import { resolveSpendLines, simulateAdditions, simulateCatalog, TYPICAL_SPEND_NOTE, typicalSpendLines, } from "../../engine/simulate.js";
9
- import { currencyName } from "../../engine/text.js";
9
+ import { categoryPhrase, currencyName, NETWORK_NAMES, } from "../../engine/text.js";
10
10
  import { EMPTY_WALLET } from "../../engine/transient.js";
11
11
  import { formatCardInfo } from "../../format/cards.js";
12
12
  import { formatCheatSheet } from "../../format/cheat-sheet.js";
@@ -16,6 +16,14 @@ import { formatRecommendation } from "../../format/recommendation.js";
16
16
  import { ALL_CARDS_NOTE, fail } from "../context.js";
17
17
  import { ChannelInput, DateInput, Merchant, settings, } from "../inputs.js";
18
18
  import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, OfferComparisonSchema, PurchaseAnswerSchema, SpendingSimulationSchema, } from "../schemas.js";
19
+ const SOURCES = {
20
+ override: "from earnrate's merchant list",
21
+ seed: "from earnrate's merchant list",
22
+ llm: 'classified by Claude',
23
+ caller: 'from the category given with the request',
24
+ category: 'a kind of purchase, not a store',
25
+ none: 'not recognized',
26
+ };
19
27
  const TOP = 10;
20
28
  // The tools that answer without a saved wallet: from every card, the cards
21
29
  // named in the conversation, or a saved wallet if there is one.
@@ -24,7 +32,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
24
32
  const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
25
33
  server.registerTool('best_card_for_purchase', {
26
34
  title: 'Best card for a purchase',
27
- description: "Which 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. Never needs or returns card numbers.",
35
+ description: "Which 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. Never needs or returns card numbers.",
28
36
  inputSchema: z.object({
29
37
  merchant: Merchant,
30
38
  amount: z
@@ -55,6 +63,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
55
63
  country: 'US',
56
64
  };
57
65
  const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
66
+ const retry = resolved.merchant.source === 'none'
67
+ ? `earnrate doesn't recognize "${merchant}", so this answer uses only base rates and may miss the best card. If you can tell what kind of business it is (a restaurant, a grocery store, a gas station), call best_card_for_purchase again with category; otherwise ask the user.\n\n`
68
+ : '';
58
69
  if (wallet === null) {
59
70
  const explored = exploreCards(catalog, {
60
71
  merchant,
@@ -69,7 +80,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
69
80
  content: [
70
81
  {
71
82
  type: 'text',
72
- text: `${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
83
+ text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
73
84
  },
74
85
  ],
75
86
  structuredContent: {
@@ -89,7 +100,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
89
100
  content: [
90
101
  {
91
102
  type: 'text',
92
- text: formatRecommendation(output, purchase, { all: true }),
103
+ text: `${retry}${formatRecommendation(output, purchase, { all: true })}`,
93
104
  },
94
105
  ],
95
106
  structuredContent: output,
@@ -109,7 +120,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
109
120
  const resolved = await ctx.resolve(merchant, channel ?? null, category ?? null);
110
121
  const m = resolved.merchant;
111
122
  const text = [
112
- `${m.resolvedName}: ${m.category}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, from ${m.source}.`,
123
+ `${m.resolvedName}: ${categoryPhrase(catalog, m.category)}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, ${SOURCES[m.source]}.`,
124
+ m.accepts === null
125
+ ? m.source === 'none'
126
+ ? 'Which card networks it takes is unknown.'
127
+ : 'Takes all major card networks.'
128
+ : `Takes ${m.accepts.map((n) => NETWORK_NAMES[n] ?? n).join(', ')} only.`,
129
+ ...(m.ambiguity ? [m.ambiguity.note] : []),
113
130
  ...(m.inference
114
131
  ? [`Claude's reasoning: ${m.inference.rationale}`]
115
132
  : []),
@@ -122,7 +139,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
122
139
  });
123
140
  server.registerTool('compare_welcome_offers', {
124
141
  title: 'Compare welcome offers',
125
- 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. 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.",
142
+ 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.",
126
143
  inputSchema: z.object({
127
144
  ...OfferFilters,
128
145
  valuation: CatalogFilters.valuation,
@@ -197,7 +214,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
197
214
  });
198
215
  server.registerTool('simulate_spending', {
199
216
  title: 'Simulate a year of spending',
200
- 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. 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.',
217
+ 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.',
201
218
  inputSchema: z.object({
202
219
  spending: z
203
220
  .array(z.object({
@@ -1,10 +1,35 @@
1
1
  import { z } from 'zod';
2
+ import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
+ import { choiceName, money, words } from "../../engine/text.js";
2
4
  import { walletSummary } from "../../engine/wallet-summary.js";
3
5
  import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
4
6
  import { fail } from "../context.js";
5
7
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
6
8
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
7
9
  const WRITE = { readOnlyHint: false, openWorldHint: false };
10
+ // What update_card changed, so the reply confirms the values it saved.
11
+ function changes(args) {
12
+ const out = [];
13
+ for (const [choice, options] of Object.entries(args.picks ?? {}))
14
+ out.push(`${choiceName(choice)} picks: ${options.map(words).join(', ')}`);
15
+ if (args.checkoutLabel === null)
16
+ out.push('checkout name cleared');
17
+ else if (args.checkoutLabel !== undefined)
18
+ out.push(`checkout name "${args.checkoutLabel}"`);
19
+ if (args.anniversary !== undefined)
20
+ out.push(`anniversary ${args.anniversary}`);
21
+ if (args.activated !== undefined)
22
+ out.push(args.activated.length === 0
23
+ ? 'nothing activated'
24
+ : `activated: ${args.activated.join(', ')}`);
25
+ for (const [key, spent] of Object.entries(args.capSpent ?? {}))
26
+ out.push(`${money(spent)} already spent on ${key} this period`);
27
+ if (args.bonus === null)
28
+ out.push('welcome bonus no longer tracked');
29
+ else if (args.bonus !== undefined)
30
+ out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
31
+ return out;
32
+ }
8
33
  // The tools that read or change a saved wallet.
9
34
  export function registerWalletTools(server, deps, ctx, inputs) {
10
35
  const { catalog, store } = deps;
@@ -27,7 +52,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
27
52
  });
28
53
  server.registerTool('record_purchase', {
29
54
  title: 'Record a purchase',
30
- 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 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 and no open welcome bonus are acknowledged and nothing is written.",
55
+ 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 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 and no open welcome bonus are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed.",
31
56
  inputSchema: z.object({
32
57
  cardId: z
33
58
  .string()
@@ -60,6 +85,8 @@ export function registerWalletTools(server, deps, ctx, inputs) {
60
85
  date: date ?? deps.today(),
61
86
  };
62
87
  const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
88
+ if (resolved.merchant.source === 'none')
89
+ return fail(`earnrate doesn't recognize "${merchant}", so it can't tell which caps this purchase counts toward. Nothing was recorded. Call record_purchase again with category if you can tell what kind of business it is (a restaurant, a grocery store, a gas station), or ask the user. If nobody knows, category "other" records it toward a welcome bonus without touching any cap.`);
63
90
  const result = await store.record(purchase, resolved.merchant);
64
91
  if (!result.ok)
65
92
  return fail(`Couldn't record it: ${result.error}`);
@@ -104,11 +131,18 @@ export function registerWalletTools(server, deps, ctx, inputs) {
104
131
  });
105
132
  server.registerTool('update_card', {
106
133
  title: 'Update a card',
107
- 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+.',
134
+ description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, activated categories, cap spending so far (capSpent) 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+.',
108
135
  inputSchema: CardChange,
109
136
  outputSchema: WalletChangeSchema,
110
137
  annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
111
- }, async (args) => ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId}).`));
138
+ }, async (args) => {
139
+ const what = changes(args);
140
+ const card = catalog.cards.find((c) => c.id === args.cardId);
141
+ const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
142
+ ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
143
+ : '';
144
+ return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
145
+ });
112
146
  server.registerTool('remove_card', {
113
147
  title: 'Remove a card',
114
148
  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.",
@@ -118,11 +152,19 @@ export function registerWalletTools(server, deps, ctx, inputs) {
118
152
  }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
119
153
  server.registerTool('set_memberships', {
120
154
  title: 'Set memberships',
121
- 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.",
155
+ 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.",
122
156
  inputSchema: z.object({ memberships: inputs.AllMemberships }),
123
157
  outputSchema: WalletChangeSchema,
124
158
  annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
125
- }, async ({ memberships }) => ctx.changed(await store.setMemberships(memberships), memberships.length > 0
126
- ? `Memberships set to ${memberships.join(', ')}.`
127
- : 'Memberships cleared.', { notes: false }));
159
+ }, async ({ memberships }) => {
160
+ const result = await store.setMemberships(memberships);
161
+ const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
162
+ const card = catalog.cards.find((c) => c.id === held.id);
163
+ return card ? membershipsFor(card) : [];
164
+ }));
165
+ const unused = memberships.filter((m) => !used.has(m));
166
+ return ctx.changed(result, `${memberships.length > 0
167
+ ? `Memberships set to ${memberships.join(', ')}.`
168
+ : 'Memberships cleared.'}${unused.length > 0 ? ` ${unused.join(', ')} ${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 });
169
+ });
128
170
  }
@@ -79,6 +79,9 @@ export const WelcomeOfferSchema = z
79
79
  // the page showed different offers on different visits. The amount
80
80
  // above is then the most it shows.
81
81
  varies: z.literal(true).optional(),
82
+ // Other cards whose holders the issuer says can't get this offer
83
+ // (Southwest's personal cards, Blue Cash Everyday with Preferred).
84
+ not_with: z.array(KebabId).min(1).optional(),
82
85
  note: z.string().optional(),
83
86
  })
84
87
  .refine((o) => (o.points === undefined) !== (o.dollars === undefined), 'a welcome offer needs points or dollars, not both');
@@ -143,6 +146,17 @@ export const CardSchema = z
143
146
  // When the first year differs, usually a waived fee.
144
147
  first_year_fee: z.number().nonnegative().optional(),
145
148
  welcome_offer: WelcomeOfferSchema.optional(),
149
+ // A paid membership the card can't be had without (Costco Anywhere). Its
150
+ // cost counts like an annual fee for anyone not already paying for it.
151
+ requires_membership: z
152
+ .strictObject({
153
+ id: KebabId,
154
+ name: z.string().min(1), // "a Costco membership"
155
+ annual_fee: z.number().nonnegative(), // the basic tier, dollars
156
+ // Only members can buy here, so spending here means a member.
157
+ members_shop_at: z.array(KebabId).default([]),
158
+ })
159
+ .optional(),
146
160
  pools_into: z
147
161
  .strictObject({
148
162
  currency: CurrencyId,
@@ -42,7 +42,11 @@ export const WalletCardSchema = z.strictObject({
42
42
  })
43
43
  .optional(),
44
44
  });
45
+ // The wallet file's format. A file without `version` is version 1; bump it
46
+ // when a change would make older earnrate misread newer files.
47
+ export const WALLET_VERSION = 1;
45
48
  export const WalletSchema = z.strictObject({
49
+ version: z.number().int().positive().optional(),
46
50
  valuations: z.record(CurrencyId, Valuation).default({}),
47
51
  memberships: z.array(KebabId).default([]),
48
52
  cards: z.array(WalletCardSchema).min(1),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -1,6 +1,7 @@
1
1
  # Copy to ~/.earnrate/wallet.yaml (or point $EARNRATE_WALLET at a copy).
2
2
  # Valuations default to data/currencies.yaml; set one here only to override it.
3
3
 
4
+ version: 1
4
5
  valuations:
5
6
  sofi-rewards: { redemption: sofi-account }
6
7
  memberships: [amazon-prime]