earnrate 0.2.15 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -315,7 +315,7 @@ earnrate needs no account. The local version has no telemetry; the hosted server
315
315
  - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins. When you ask about a store earnrate doesn't recognize, its name goes in `~/.earnrate/missed-merchants.json` (`earnrate missed` lists them; `EARNRATE_MISSED_MERCHANTS=off` keeps no list).
316
316
  - **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
317
317
  - **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
318
- - **earnrate.dev:** everything you enter on the site stays in your browser, except when you add the cheat sheet to Apple Wallet: then pass.earnrate.dev receives what the pass needs (card IDs, picks, activations, checkout names, memberships and the date) and keeps none of it. The site counts which of its parts get used, by name only, and counts visits with Cloudflare Web Analytics, without cookies.
318
+ - **earnrate.dev:** everything you enter on the site stays in your browser, except when you add the cheat sheet to Apple Wallet: then pass.earnrate.dev receives what the pass needs (card IDs, picks, activations, checkout names, memberships and the date) and keeps none of it. If you turn on syncing with Claude, the wallet (cards, picks, activations, memberships, point values, cap and spending totals, credits used, opening dates) goes to mcp.earnrate.dev and is stored encrypted, like the connector's; a purchase you record is sent so its totals can be updated, and only the totals are kept. Searches, stores and amounts you ask about still never leave the browser. The sign-in stays in a cookie the page's scripts can't read. The site counts which of its parts get used, by name only, and counts visits with Cloudflare Web Analytics, without cookies.
319
319
  - **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months (a week, if it's never used); the record that an app is connected goes after 90 days unused.
320
320
  - **Terms:** [earnrate.dev/terms](https://earnrate.dev/terms).
321
321
  - **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
package/dist/cli/args.js CHANGED
@@ -95,6 +95,8 @@ export function parseCliArgs(argv) {
95
95
  llm: { type: 'boolean' },
96
96
  cards: { type: 'string' },
97
97
  member: { type: 'string', multiple: true },
98
+ enrolled: { type: 'string', multiple: true },
99
+ opened: { type: 'string', multiple: true },
98
100
  help: { type: 'boolean', short: 'h' },
99
101
  },
100
102
  });
@@ -129,6 +131,25 @@ export function parseCliArgs(argv) {
129
131
  const paying = paymentOf(values.paying);
130
132
  if ('problem' in paying)
131
133
  return { kind: 'error', message: paying.problem };
134
+ const cards = cardList(values.cards);
135
+ const perCard = (flag, example) => {
136
+ const out = {};
137
+ for (const pair of values[flag] ?? []) {
138
+ const [id = '', value = ''] = pair.split('=');
139
+ if (id.trim() === '' || value.trim() === '')
140
+ return `--${flag} takes <card>=<${flag === 'opened' ? 'date' : 'credits'}>, like ${example}.`;
141
+ if (!cards?.includes(id.trim()))
142
+ return `--${flag} is for a card named with --cards; ${id.trim()} isn't one.`;
143
+ out[id.trim()] = value.trim();
144
+ }
145
+ return out;
146
+ };
147
+ const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
148
+ if (typeof enrolled === 'string')
149
+ return { kind: 'error', message: enrolled };
150
+ const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
151
+ if (typeof opened === 'string')
152
+ return { kind: 'error', message: opened };
132
153
  return {
133
154
  kind: 'run',
134
155
  options: {
@@ -143,8 +164,16 @@ export function parseCliArgs(argv) {
143
164
  all: values.all ?? false,
144
165
  category: values.category ?? null,
145
166
  llm: values.llm ?? null,
146
- cards: cardList(values.cards),
167
+ cards,
147
168
  members: values.member ?? [],
169
+ enrolled: Object.fromEntries(Object.entries(enrolled).map(([id, list]) => [
170
+ id,
171
+ list
172
+ .split(',')
173
+ .map((c) => c.trim())
174
+ .filter(Boolean),
175
+ ])),
176
+ opened,
148
177
  },
149
178
  };
150
179
  }
package/dist/cli/run.js CHANGED
@@ -70,6 +70,11 @@ Options:
70
70
  ${PAYING_HELP}
71
71
  --cards <ids> Answer for these cards instead of your wallet, comma
72
72
  separated: --cards amex-gold,chase-prime-visa.
73
+ --enrolled <card>=<credits>
74
+ For a named card, statement credits you've enrolled
75
+ in: --enrolled amex-platinum=lululemon. Repeat per card.
76
+ --opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
77
+ a first-year rate. Repeat per card.
73
78
  --member <name> A membership you have, in place of the saved ones.
74
79
  --category <id> For a merchant earnrate doesn't know: its category,
75
80
  e.g. grocery.supermarket. Ignored for known merchants.
@@ -106,7 +111,11 @@ export async function cardsToUse(catalog, file, io, named) {
106
111
  if (named.cards !== null) {
107
112
  const saved = await savedSettings(io);
108
113
  const wallet = walletFromCards(catalog, {
109
- cards: named.cards.map((id) => ({ id })),
114
+ cards: named.cards.map((id) => ({
115
+ id,
116
+ ...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
117
+ ...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
118
+ })),
110
119
  memberships: named.members.length > 0 ? named.members : saved.memberships,
111
120
  }, io.today);
112
121
  if (typeof wallet === 'string') {
@@ -86,7 +86,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
86
86
  ];
87
87
  return [
88
88
  `Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
89
- ? `It ${where.join(' and ')}, so those are the places to use it.`
89
+ ? `It ${where.join(' and ')}, so ${matches.length + near.length === 1 ? "that's the place" : 'those are the places'} to use it.`
90
90
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
91
91
  ];
92
92
  });
@@ -24,7 +24,7 @@ shortName) {
24
24
  items.push({
25
25
  kind: 'activate',
26
26
  cardId: card.id,
27
- text: `Activate ${possessive(name)} bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
27
+ text: `Activate ${possessive(name)} bonus ${a.where}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
28
28
  due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
29
29
  });
30
30
  }
@@ -223,6 +223,10 @@ export function exploreCards(catalog, query, options, resolved) {
223
223
  ...membershipNeeded(catalog, card, members),
224
224
  ],
225
225
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
226
+ needsMembership: card.requires_membership !== undefined &&
227
+ !members.has(card.requires_membership)
228
+ ? card.requires_membership
229
+ : null,
226
230
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
227
231
  });
228
232
  }
@@ -303,7 +307,7 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
303
307
  function exploreWarnings(catalog, merchant) {
304
308
  const warnings = [];
305
309
  if (merchant.source === 'none') {
306
- warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. Try a category instead.`);
310
+ warnings.push(`earnrate doesn't know "${merchant.input}" yet, so only base rates apply. Try a category, or the name of a larger chain.`);
307
311
  }
308
312
  if (merchant.ambiguity) {
309
313
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
@@ -50,7 +50,7 @@ export function membershipCosts(catalog, cards, paid) {
50
50
  export function membershipText(catalog, id) {
51
51
  const m = membership(catalog, id);
52
52
  const name = withArticle(m?.name ?? words(id));
53
- return m?.annual_fee === undefined
53
+ return m?.annual_fee === undefined || m.annual_fee === 0
54
54
  ? name
55
55
  : `${name} (${money(m.annual_fee)} a year)`;
56
56
  }
@@ -68,23 +68,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
68
68
  pending ? ', once activated' : '',
69
69
  payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
70
70
  ].join('|');
71
- const line = lines.get(key) ?? { at: [], on: [] };
72
- const whats = line[rulePreposition(catalog, [r])];
73
- for (const part of ruleParts(catalog, r, card))
74
- if (!whats.includes(part))
75
- whats.push(part);
76
- lines.set(key, line);
71
+ lines.set(key, [...(lines.get(key) ?? []), r]);
77
72
  }
78
73
  return [
79
- ...[...lines].map(([key, { at, on }]) => {
74
+ ...[...lines].map(([key, rules]) => {
80
75
  const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
81
76
  const after = `${limit}${activate}`;
82
- // "at gas stations and on transit" when one line mixes the two.
83
- const where = [
84
- ...(at.length > 0 ? [`at ${listed(at)}`] : []),
85
- ...(on.length > 0 ? [`on ${listed(on)}`] : []),
86
- ].join(at.length > 1 ? ', and ' : ' and ');
87
- return `${head} ${where}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
77
+ return `${head} ${wherePhrase(catalog, card, rules)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
88
78
  }),
89
79
  ...(shown.some((r) => r.match.any &&
90
80
  r.geography !== 'foreign_only' &&
@@ -96,6 +86,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
96
86
  }
97
87
  // Bonuses current or starting soon that the card hasn't activated, one per
98
88
  // activation group (one activation covers a quarter's categories).
89
+ // "at gas stations and restaurants, and on transit": each rule's parts after
90
+ // its own preposition, the "at" ones first.
91
+ export function wherePhrase(catalog, card, rules) {
92
+ const parts = { at: [], on: [] };
93
+ for (const r of rules) {
94
+ const list = parts[rulePreposition(catalog, [r])];
95
+ for (const part of ruleParts(catalog, r, card))
96
+ if (!list.includes(part))
97
+ list.push(part);
98
+ }
99
+ const { at, on } = parts;
100
+ return [
101
+ ...(at.length > 0 ? [`at ${listed(at)}`] : []),
102
+ ...(on.length > 0 ? [`on ${listed(on)}`] : []),
103
+ ].join(at.length > 1 ? ', and ' : ' and ');
104
+ }
99
105
  export function activationsDue(catalog, card, held, today) {
100
106
  const seen = new Set();
101
107
  const out = [];
@@ -111,7 +117,7 @@ export function activationsDue(catalog, card, held, today) {
111
117
  what: listed([
112
118
  ...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
113
119
  ]),
114
- preposition: rulePreposition(catalog, rules),
120
+ where: wherePhrase(catalog, card, rules),
115
121
  from: first?.valid_from ?? null,
116
122
  to: first?.valid_to ?? null,
117
123
  activateBy: first?.activate_by ?? null,
@@ -15,9 +15,11 @@ export function cardText(catalog) {
15
15
  .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
16
16
  .replace(/ Card by /, ' by '));
17
17
  }
18
- function feeLabel(id) {
18
+ // `membership: false` leaves out a membership the card needs, for lists
19
+ // whose own conditions say it only when it applies.
20
+ function feeLabel(id, { membership = true } = {}) {
19
21
  const card = cards.get(id);
20
- const m = card?.requires_membership;
22
+ const m = membership ? card?.requires_membership : undefined;
21
23
  const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
22
24
  const fee = card?.annual_fee;
23
25
  if (fee === undefined)
@@ -49,6 +49,14 @@ const NamedCard = z.object({
49
49
  .array(z.string())
50
50
  .optional()
51
51
  .describe('Bonuses the user has activated with the issuer, as rule ids from search_cards.'),
52
+ enrolled: z
53
+ .array(z.string())
54
+ .optional()
55
+ .describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
56
+ opened: z
57
+ .string()
58
+ .optional()
59
+ .describe('The day the account opened (YYYY-MM-DD), for a card with a first-year rate. Optional; without it the card is ranked at its standard rates.'),
52
60
  });
53
61
  const cardFields = {
54
62
  checkoutLabel: z
@@ -413,6 +413,7 @@ export const ExplorationSchema = z.object({
413
413
  conditions: z.array(z.string()),
414
414
  evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
415
415
  withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
416
+ needsMembership: z.string().nullable(),
416
417
  })),
417
418
  excluded: z.array(ExcludedCardSchema),
418
419
  assumptions: z.array(z.string()),
@@ -5,19 +5,56 @@ import { listed } from "../../engine/text.js";
5
5
  import { savedSettingsText } from "../../engine/wallet-summary.js";
6
6
  import { fail } from "../context.js";
7
7
  import { WalletChangeSchema } from "../schemas.js";
8
+ const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
9
+ // Everything a saved wallet holds, in words; empty when there's nothing.
10
+ const contentsOf = (catalog, w) => [
11
+ ...(w.cards.length > 0
12
+ ? [`${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`]
13
+ : []),
14
+ ...savedSettingsText(catalog, w),
15
+ ];
16
+ const ImportInput = z.object({
17
+ yaml: z
18
+ .string()
19
+ .min(1)
20
+ .max(100_000)
21
+ .describe('The wallet.yaml text, as the user gave it.'),
22
+ replace: z
23
+ .boolean()
24
+ .optional()
25
+ .describe('true to replace what the wallet already holds, after the user confirms.'),
26
+ });
27
+ export function importHandler(catalog, store, ctx) {
28
+ const contents = (w) => contentsOf(catalog, w);
29
+ return {
30
+ input: ImportInput,
31
+ run: async ({ yaml, replace }) => {
32
+ const parsed = parseWalletYaml(yaml, catalog);
33
+ if (typeof parsed === 'string')
34
+ return fail(parsed);
35
+ let current;
36
+ try {
37
+ current = await store.load();
38
+ }
39
+ catch (e) {
40
+ if (replace !== true)
41
+ 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.`);
42
+ current = null;
43
+ }
44
+ const before = current === null ? [] : contents(current);
45
+ if (before.length > 0 && replace !== true)
46
+ return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
47
+ return ctx.changed(await store.replace(parsed), before.length > 0
48
+ ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
49
+ : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
50
+ },
51
+ };
52
+ }
8
53
  // A saved wallet on the hosted server has no file to copy or delete, so
9
54
  // these do it.
10
55
  export function registerAccountTools(server, catalog, store, ctx) {
11
- const cardsText = (n) => `${String(n)} ${n === 1 ? 'card' : 'cards'}`;
12
- // Everything a saved wallet holds, in words; empty when there's nothing.
13
- const contents = (w) => [
14
- ...(w.cards.length > 0
15
- ? [
16
- `${cardsText(w.cards.length)} (${w.cards.map((c) => c.id).join(', ')})`,
17
- ]
18
- : []),
19
- ...savedSettingsText(catalog, w),
20
- ];
56
+ const contents = (w) => contentsOf(catalog, w);
57
+ const importWallet = importHandler(catalog, store, ctx);
21
58
  server.registerTool('export_wallet', {
22
59
  title: 'Export the wallet',
23
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.',
@@ -42,17 +79,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
42
79
  server.registerTool('import_wallet', {
43
80
  title: 'Import a wallet',
44
81
  description: 'Replaces the saved wallet with a wallet.yaml from the earnrate command line or earnrate.dev, when the user pastes or attaches one. Wallet files only: never statements or card numbers. If anything is already saved (cards, memberships or point values), it refuses unless replace is true: confirm with the user first.',
45
- inputSchema: z.object({
46
- yaml: z
47
- .string()
48
- .min(1)
49
- .max(100_000)
50
- .describe('The wallet.yaml text, as the user gave it.'),
51
- replace: z
52
- .boolean()
53
- .optional()
54
- .describe('true to replace what the wallet already holds, after the user confirms.'),
55
- }),
82
+ inputSchema: importWallet.input,
56
83
  outputSchema: WalletChangeSchema,
57
84
  annotations: {
58
85
  title: 'Import a wallet',
@@ -61,26 +88,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
61
88
  idempotentHint: true,
62
89
  openWorldHint: false,
63
90
  },
64
- }, async ({ yaml, replace }) => {
65
- const parsed = parseWalletYaml(yaml, catalog);
66
- if (typeof parsed === 'string')
67
- return fail(parsed);
68
- let current;
69
- try {
70
- current = await store.load();
71
- }
72
- catch (e) {
73
- if (replace !== true)
74
- 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.`);
75
- current = null;
76
- }
77
- const before = current === null ? [] : contents(current);
78
- if (before.length > 0 && replace !== true)
79
- return fail(`This would replace what's saved: ${listed(before)}. Ask the user, then call again with replace: true.`);
80
- return ctx.changed(await store.replace(parsed), before.length > 0
81
- ? `Replaced what was saved (${listed(before)}) with the imported wallet: ${listed(contents(parsed))}.`
82
- : `Imported a wallet with ${listed(contents(parsed)) || 'nothing in it'}.`, { notes: false });
83
- });
91
+ }, (args) => importWallet.run(args));
84
92
  server.registerTool('delete_wallet', {
85
93
  title: 'Delete the wallet',
86
94
  description: "Deletes the saved wallet and its recovery code now and for good, and signs out every 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, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
@@ -63,8 +63,196 @@ 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
- export function registerWalletTools(server, deps, ctx, inputs) {
66
+ // The saved wallet with its summary and to-do list, as list_wallet shows it.
67
+ export async function walletListing(deps, ctx) {
68
+ const read = await ctx.readableWallet();
69
+ if (typeof read === 'string')
70
+ return read;
71
+ const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
72
+ const base = walletSummary(deps.catalog, read.wallet, deps.today());
73
+ const summary = {
74
+ ...base,
75
+ started,
76
+ checklist: checklist(deps.catalog, read.wallet, deps.today(), base, cardText(deps.catalog).shortName),
77
+ };
78
+ return { ...read, summary };
79
+ }
80
+ export function walletHandlers(deps, ctx, inputs) {
67
81
  const { catalog, store } = deps;
82
+ const listWallet = {
83
+ input: z.object({}),
84
+ run: async () => {
85
+ const found = await walletListing(deps, ctx);
86
+ if (typeof found === 'string')
87
+ return fail(found);
88
+ const { start, summary } = found;
89
+ const when = summary.started === null
90
+ ? `This is ${ctx.walletName}.\n`
91
+ : `This is ${ctx.walletName}. ${startedText(summary.started, Date.now())}\n`;
92
+ return {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: `${when}${start === null ? '' : `${start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
97
+ },
98
+ ],
99
+ structuredContent: summary,
100
+ };
101
+ },
102
+ };
103
+ const RecordInput = z.object({
104
+ cardId: z
105
+ .string()
106
+ .min(1)
107
+ .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
108
+ merchant: Merchant,
109
+ amount: z.number().positive().describe('Amount spent in US dollars.'),
110
+ channel: ChannelInput,
111
+ date: DateInput,
112
+ category: inputs.RecordCategoryInput,
113
+ });
114
+ const recordPurchase = {
115
+ input: RecordInput,
116
+ run: async ({ cardId, merchant, amount, channel, date, category }) => {
117
+ const w = await ctx.wallet();
118
+ if (typeof w === 'string')
119
+ return fail(w);
120
+ if (!w.cards.some((c) => c.id === cardId))
121
+ return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
122
+ const purchase = {
123
+ cardId,
124
+ merchant,
125
+ amount,
126
+ channel: channel ?? null,
127
+ date: date ?? deps.today(),
128
+ };
129
+ const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
130
+ if (resolved.merchant.source === 'none')
131
+ 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.`);
132
+ const result = await store.record(purchase, resolved.merchant);
133
+ if (!result.ok)
134
+ return fail(`Couldn't record it: ${result.error}`);
135
+ const { cap: booking, bonus, milestones, credit } = result;
136
+ const capLabel = booking
137
+ ? (walletSummary(catalog, w, purchase.date)
138
+ .cards.find((c) => c.cardId === cardId)
139
+ ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
140
+ : null;
141
+ const output = booking || bonus || milestones.length > 0 || credit
142
+ ? {
143
+ recorded: true,
144
+ booking,
145
+ bonus,
146
+ milestones,
147
+ credit,
148
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
149
+ }
150
+ : {
151
+ recorded: false,
152
+ booking: null,
153
+ bonus: null,
154
+ milestones: [],
155
+ credit: null,
156
+ 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.`,
157
+ };
158
+ return {
159
+ content: [
160
+ {
161
+ type: 'text',
162
+ text: [output.message, ...resolved.notices].join('\n'),
163
+ },
164
+ ],
165
+ structuredContent: output,
166
+ };
167
+ },
168
+ };
169
+ const addCard = {
170
+ input: CardChange,
171
+ run: async (args) => {
172
+ const result = await store.addCard(cardInput(args), deps.today());
173
+ const started = !result.ok || !result.created
174
+ ? ''
175
+ : deps.savedWallets === 'account'
176
+ ? ', the first card in your saved wallet'
177
+ : `, starting a new wallet at ${store.where}`;
178
+ return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
179
+ },
180
+ };
181
+ const updateCard = {
182
+ input: CardChange,
183
+ run: async (args) => {
184
+ const what = changes(catalog, args);
185
+ 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."
188
+ : '';
189
+ return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
190
+ },
191
+ };
192
+ const RemoveInput = z.object({ cardId: CardId });
193
+ const removeCard = {
194
+ input: RemoveInput,
195
+ run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
196
+ };
197
+ const MembershipsInput = z.object({ memberships: inputs.AllMemberships });
198
+ const setMemberships = {
199
+ input: MembershipsInput,
200
+ run: async ({ memberships }) => {
201
+ const result = await store.setMemberships(memberships);
202
+ const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
203
+ const card = catalog.cards.find((c) => c.id === held.id);
204
+ return card ? membershipsFor(card) : [];
205
+ }));
206
+ const unused = memberships.filter((m) => !used.has(m));
207
+ return ctx.changed(result, `${memberships.length > 0
208
+ ? `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 });
210
+ },
211
+ };
212
+ const PointValuesInput = z.object({
213
+ values: z
214
+ .record(z.string(), z.number().nullable())
215
+ .optional()
216
+ .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
217
+ profile: z
218
+ .enum(['flat', 'points-heavy'])
219
+ .optional()
220
+ .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
221
+ });
222
+ const setPointValues = {
223
+ input: PointValuesInput,
224
+ run: async ({ values, profile }) => {
225
+ const given = currencyChanges(catalog, values ?? {});
226
+ if (!given.ok)
227
+ return fail(given.error);
228
+ const changes = {
229
+ ...(profile ? VALUATION_PROFILES[profile] : {}),
230
+ ...given.changes,
231
+ };
232
+ if (Object.keys(changes).length === 0)
233
+ return fail('Give values (cents per point by currency) or a profile.');
234
+ const result = await store.setValuations(changes);
235
+ const set = Object.entries(changes).map(([id, cents]) => cents === null
236
+ ? `${currencyLabel(catalog, id)} back to the default`
237
+ : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
238
+ return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
239
+ notes: false,
240
+ });
241
+ },
242
+ };
243
+ return {
244
+ list_wallet: listWallet,
245
+ record_purchase: recordPurchase,
246
+ add_card: addCard,
247
+ update_card: updateCard,
248
+ remove_card: removeCard,
249
+ set_memberships: setMemberships,
250
+ set_point_values: setPointValues,
251
+ };
252
+ }
253
+ export function registerWalletTools(server, deps, ctx, inputs) {
254
+ const { catalog } = deps;
255
+ const tools = walletHandlers(deps, ctx, inputs);
68
256
  server.registerTool('list_wallet', {
69
257
  title: 'List wallet',
70
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}`,
@@ -75,44 +263,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
263
  destructiveHint: false,
76
264
  openWorldHint: false,
77
265
  },
78
- }, async () => {
79
- const read = await ctx.readableWallet();
80
- if (typeof read === 'string')
81
- return fail(read);
82
- const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
83
- const base = walletSummary(catalog, read.wallet, deps.today());
84
- const summary = {
85
- ...base,
86
- started,
87
- checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
88
- };
89
- const when = started === null
90
- ? `This is ${ctx.walletName}.\n`
91
- : `This is ${ctx.walletName}. ${startedText(started, Date.now())}\n`;
92
- return {
93
- content: [
94
- {
95
- type: 'text',
96
- text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
97
- },
98
- ],
99
- structuredContent: summary,
100
- };
101
- });
266
+ }, () => tools.list_wallet.run());
102
267
  server.registerTool('record_purchase', {
103
268
  title: 'Record a purchase',
104
269
  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}`,
105
- inputSchema: z.object({
106
- cardId: z
107
- .string()
108
- .min(1)
109
- .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
110
- merchant: Merchant,
111
- amount: z.number().positive().describe('Amount spent in US dollars.'),
112
- channel: ChannelInput,
113
- date: DateInput,
114
- category: inputs.RecordCategoryInput,
115
- }),
270
+ inputSchema: tools.record_purchase.input,
116
271
  outputSchema: RecordResultSchema,
117
272
  annotations: {
118
273
  title: 'Record a purchase',
@@ -121,62 +276,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
121
276
  idempotentHint: false,
122
277
  openWorldHint: deps.llm !== null,
123
278
  },
124
- }, async ({ cardId, merchant, amount, channel, date, category }) => {
125
- const w = await ctx.wallet();
126
- if (typeof w === 'string')
127
- return fail(w);
128
- if (!w.cards.some((c) => c.id === cardId))
129
- return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
130
- const purchase = {
131
- cardId,
132
- merchant,
133
- amount,
134
- channel: channel ?? null,
135
- date: date ?? deps.today(),
136
- };
137
- const resolved = await ctx.resolve(merchant, purchase.channel, category ?? null);
138
- if (resolved.merchant.source === 'none')
139
- 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.`);
140
- const result = await store.record(purchase, resolved.merchant);
141
- if (!result.ok)
142
- return fail(`Couldn't record it: ${result.error}`);
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;
149
- const output = booking || bonus || milestones.length > 0 || credit
150
- ? {
151
- recorded: true,
152
- booking,
153
- bonus,
154
- milestones,
155
- credit,
156
- message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
157
- }
158
- : {
159
- recorded: false,
160
- booking: null,
161
- bonus: null,
162
- milestones: [],
163
- credit: null,
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.`,
165
- };
166
- return {
167
- content: [
168
- {
169
- type: 'text',
170
- text: [output.message, ...resolved.notices].join('\n'),
171
- },
172
- ],
173
- structuredContent: output,
174
- };
175
- });
279
+ }, (args) => tools.record_purchase.run(args));
176
280
  server.registerTool('add_card', {
177
281
  title: 'Add a card',
178
282
  description: `Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers. ${ctx.walletUse}`,
179
- inputSchema: CardChange,
283
+ inputSchema: tools.add_card.input,
180
284
  outputSchema: WalletChangeSchema,
181
285
  annotations: {
182
286
  title: 'Add a card',
@@ -184,19 +288,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
184
288
  destructiveHint: false,
185
289
  idempotentHint: false,
186
290
  },
187
- }, async (args) => {
188
- const result = await store.addCard(cardInput(args), deps.today());
189
- const started = !result.ok || !result.created
190
- ? ''
191
- : deps.savedWallets === 'account'
192
- ? ', the first card in your saved wallet'
193
- : `, starting a new wallet at ${store.where}`;
194
- return ctx.changed(result, `Added ${ctx.cardName(args.cardId)} (${args.cardId})${started}.`);
195
- });
291
+ }, (args) => tools.add_card.run(args));
196
292
  server.registerTool('update_card', {
197
293
  title: 'Update a card',
198
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}`,
199
- inputSchema: CardChange,
295
+ inputSchema: tools.update_card.input,
200
296
  outputSchema: WalletChangeSchema,
201
297
  annotations: {
202
298
  title: 'Update a card',
@@ -204,18 +300,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
204
300
  destructiveHint: true,
205
301
  idempotentHint: true,
206
302
  },
207
- }, async (args) => {
208
- const what = changes(catalog, args);
209
- const card = catalog.cards.find((c) => c.id === args.cardId);
210
- const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
211
- ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
212
- : '';
213
- return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
214
- });
303
+ }, (args) => tools.update_card.run(args));
215
304
  server.registerTool('remove_card', {
216
305
  title: 'Remove a card',
217
306
  description: `Removes a card from the wallet, with its tracked cap usage, bonus progress and yearly spending. Memberships and point values stay, even when the last card goes. Only when the user says they no longer have it or don't want it considered. ${ctx.walletUse}`,
218
- inputSchema: z.object({ cardId: CardId }),
307
+ inputSchema: tools.remove_card.input,
219
308
  outputSchema: WalletChangeSchema,
220
309
  annotations: {
221
310
  title: 'Remove a card',
@@ -223,11 +312,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
223
312
  destructiveHint: true,
224
313
  idempotentHint: true,
225
314
  },
226
- }, async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }));
315
+ }, (args) => tools.remove_card.run(args));
227
316
  server.registerTool('set_memberships', {
228
317
  title: 'Set memberships',
229
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}`,
230
- inputSchema: z.object({ memberships: inputs.AllMemberships }),
319
+ inputSchema: tools.set_memberships.input,
231
320
  outputSchema: WalletChangeSchema,
232
321
  annotations: {
233
322
  title: 'Set memberships',
@@ -235,30 +324,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
235
324
  destructiveHint: true,
236
325
  idempotentHint: true,
237
326
  },
238
- }, async ({ memberships }) => {
239
- const result = await store.setMemberships(memberships);
240
- const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
241
- const card = catalog.cards.find((c) => c.id === held.id);
242
- return card ? membershipsFor(card) : [];
243
- }));
244
- const unused = memberships.filter((m) => !used.has(m));
245
- return ctx.changed(result, `${memberships.length > 0
246
- ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
247
- : '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 });
248
- });
327
+ }, (args) => tools.set_memberships.run(args));
249
328
  server.registerTool('set_point_values', {
250
329
  title: 'Set point values',
251
330
  description: `Saves what the user's points are worth to them, in cents per point, in their saved wallet; every answer then compares cards at those values unless a valuation profile is asked for. No saved cards are needed. Use it when the user says what their points are worth ("I value Chase points at 1.5¢") or that they transfer points to airlines and hotels (profile points-heavy). Values change only the currencies given; null goes back to earnrate's default. Currencies by id: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}. ${ctx.walletUse}`,
252
- inputSchema: z.object({
253
- values: z
254
- .record(z.string(), z.number().nullable())
255
- .optional()
256
- .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
257
- profile: z
258
- .enum(['flat', 'points-heavy'])
259
- .optional()
260
- .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
261
- }),
331
+ inputSchema: tools.set_point_values.input,
262
332
  outputSchema: WalletChangeSchema,
263
333
  annotations: {
264
334
  title: 'Set point values',
@@ -266,22 +336,5 @@ export function registerWalletTools(server, deps, ctx, inputs) {
266
336
  destructiveHint: true,
267
337
  idempotentHint: true,
268
338
  },
269
- }, async ({ values, profile }) => {
270
- const given = currencyChanges(catalog, values ?? {});
271
- if (!given.ok)
272
- return fail(given.error);
273
- const changes = {
274
- ...(profile ? VALUATION_PROFILES[profile] : {}),
275
- ...given.changes,
276
- };
277
- if (Object.keys(changes).length === 0)
278
- return fail('Give values (cents per point by currency) or a profile.');
279
- const result = await store.setValuations(changes);
280
- const set = Object.entries(changes).map(([id, cents]) => cents === null
281
- ? `${currencyLabel(catalog, id)} back to the default`
282
- : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
283
- return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
284
- notes: false,
285
- });
286
- });
339
+ }, (args) => tools.set_point_values.run(args));
287
340
  }
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ import { makeContext } from "./context.js";
3
+ import { makeInputs } from "./inputs.js";
4
+ import { importHandler } from "./tools/account.js";
5
+ import { walletHandlers, walletListing } from "./tools/wallet.js";
6
+ // The changes earnrate.dev can make to a synced wallet, by MCP tool name.
7
+ // import_wallet replaces it all, with the tool's own confirmation.
8
+ export const WALLET_CHANGES = [
9
+ 'add_card',
10
+ 'update_card',
11
+ 'remove_card',
12
+ 'set_memberships',
13
+ 'set_point_values',
14
+ 'record_purchase',
15
+ 'import_wallet',
16
+ ];
17
+ const ChangeRequest = z.object({
18
+ tool: z.enum(WALLET_CHANGES),
19
+ input: z.record(z.string(), z.unknown()).optional(),
20
+ });
21
+ // The hosted wallet over plain HTTP: reads give what list_wallet gives, and
22
+ // each change runs the MCP tool's own handler, so the rules and refusals
23
+ // can't drift between earnrate.dev and Claude.
24
+ export function walletApi(deps) {
25
+ const server = {
26
+ ...deps,
27
+ savedWallets: 'account',
28
+ llm: null,
29
+ version: '',
30
+ };
31
+ const ctx = makeContext(server);
32
+ const handlers = {
33
+ ...walletHandlers(server, ctx, makeInputs(deps.catalog)),
34
+ import_wallet: importHandler(deps.catalog, deps.store, ctx),
35
+ };
36
+ async function read() {
37
+ const found = await walletListing(server, ctx);
38
+ if (typeof found === 'string')
39
+ return { status: 422, body: { error: found } };
40
+ return {
41
+ status: 200,
42
+ body: { wallet: found.wallet, summary: found.summary },
43
+ };
44
+ }
45
+ // `body` is the request's parsed JSON: {"tool": "add_card", "input": {...}}.
46
+ async function change(body) {
47
+ const request = ChangeRequest.safeParse(body);
48
+ if (!request.success)
49
+ return {
50
+ status: 400,
51
+ body: {
52
+ error: `Send one change as {"tool": ..., "input": {...}}, where tool is one of ${WALLET_CHANGES.join(', ')}.`,
53
+ },
54
+ };
55
+ const { tool, input } = request.data;
56
+ const handler = handlers[tool];
57
+ const args = handler.input.safeParse(input ?? {});
58
+ if (!args.success)
59
+ return {
60
+ status: 400,
61
+ tool,
62
+ body: {
63
+ error: `Invalid input for ${tool}: ${z.prettifyError(args.error)}`,
64
+ },
65
+ };
66
+ const result = await handler.run(args.data);
67
+ const text = result.content.map((c) => c.text).join('\n');
68
+ if (result.isError)
69
+ return { status: 422, tool, body: { error: text } };
70
+ const after = await read();
71
+ return {
72
+ status: after.status,
73
+ tool,
74
+ body: after.status === 200 ? { message: text, ...after.body } : after.body,
75
+ };
76
+ }
77
+ return { read, change };
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
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",