earnrate 0.2.4 → 0.2.5

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 (62) hide show
  1. package/README.md +5 -5
  2. package/data/CHANGELOG.md +39 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-gold.yaml +4 -0
  5. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  6. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  8. package/data/cards/chase-freedom-flex.yaml +1 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  10. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  11. package/data/cards/chase-united-club.yaml +1 -1
  12. package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
  13. package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
  14. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  15. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  16. package/data/categories.yaml +12 -0
  17. package/data/currencies.yaml +2 -0
  18. package/data/memberships.yaml +33 -0
  19. package/data/merchants/overrides.yaml +25 -0
  20. package/dist/cli/cheatsheet.js +2 -2
  21. package/dist/cli/explore.js +11 -10
  22. package/dist/cli/import.js +9 -9
  23. package/dist/cli/offers.js +2 -1
  24. package/dist/cli/run.js +17 -3
  25. package/dist/cli/wallet.js +42 -0
  26. package/dist/data/file-store.js +5 -2
  27. package/dist/data/integrity.js +18 -2
  28. package/dist/data/load.js +10 -2
  29. package/dist/data/store.js +8 -1
  30. package/dist/data/wallet-edit.js +75 -0
  31. package/dist/engine/cheat-sheet.js +14 -17
  32. package/dist/engine/compare.js +8 -0
  33. package/dist/engine/explore.js +10 -21
  34. package/dist/engine/memberships.js +41 -15
  35. package/dist/engine/offers.js +16 -4
  36. package/dist/engine/profiles.js +8 -0
  37. package/dist/engine/recommend.js +10 -23
  38. package/dist/engine/setup.js +14 -16
  39. package/dist/engine/simulate.js +5 -5
  40. package/dist/engine/text.js +36 -22
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +17 -0
  43. package/dist/engine/wallet-summary.js +24 -6
  44. package/dist/format/card-text.js +15 -19
  45. package/dist/format/cards.js +3 -3
  46. package/dist/format/explore.js +14 -15
  47. package/dist/format/offers.js +10 -15
  48. package/dist/format/recommendation.js +5 -5
  49. package/dist/format/wallet.js +4 -1
  50. package/dist/mcp/context.js +31 -4
  51. package/dist/mcp/inputs.js +15 -6
  52. package/dist/mcp/schemas.js +8 -0
  53. package/dist/mcp/server.js +1 -0
  54. package/dist/mcp/tools/account.js +1 -1
  55. package/dist/mcp/tools/answers.js +28 -12
  56. package/dist/mcp/tools/wallet.js +58 -5
  57. package/dist/schema/card.js +4 -11
  58. package/dist/schema/category.js +4 -0
  59. package/dist/schema/currency.js +2 -0
  60. package/dist/schema/membership.js +15 -0
  61. package/dist/schema/merchant.js +4 -0
  62. package/package.json +1 -1
@@ -1,11 +1,10 @@
1
- import { membershipNote } from "../engine/memberships.js";
2
- import { feeText, membershipName, money, pct } from "../engine/text.js";
3
- const wholeUsd = (n) => money(Math.round(n));
4
- const membershipText = (memberships) => memberships
5
- .map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
1
+ import { membershipName, membershipNote, } from "../engine/memberships.js";
2
+ import { choiceName, feeText, money, pct, wholeDollars, words, } from "../engine/text.js";
3
+ const membershipFees = (memberships) => memberships
4
+ .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
6
5
  .join('');
7
6
  const TOP_BEST = 10;
8
- export function formatExploration(r, all) {
7
+ export function formatExploration(r, catalog, all) {
9
8
  const m = r.merchant;
10
9
  const title = m.source === 'category'
11
10
  ? m.resolvedName.toLowerCase()
@@ -24,7 +23,7 @@ export function formatExploration(r, all) {
24
23
  lines.push(` ${c}`);
25
24
  const upgrade = e.withMembership[0];
26
25
  if (upgrade)
27
- lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
26
+ lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
28
27
  for (const w of e.warnings)
29
28
  lines.push(` ! ${w}`);
30
29
  });
@@ -36,8 +35,8 @@ export function formatExploration(r, all) {
36
35
  return `${lines.join('\n')}\n`;
37
36
  }
38
37
  function picksText(picks) {
39
- const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
40
- return parts.length === 0 ? '' : ` (picks ${parts.join('; ')})`;
38
+ const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
39
+ return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
41
40
  }
42
41
  function cardNamer(catalog) {
43
42
  return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
@@ -60,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
60
59
  return 'No cards match.\n';
61
60
  const top = all ? 20 : TOP_SIMULATED;
62
61
  const lines = [
63
- `A year of this spending: ${wholeUsd(first.spend)}.`,
62
+ `A year of this spending: ${wholeDollars(first.spend)}.`,
64
63
  '',
65
64
  'Best single cards (rewards minus annual fee, per year):',
66
65
  ];
@@ -68,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
68
67
  const card = s.cards[0];
69
68
  if (!card)
70
69
  return;
71
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee${membershipText(s.memberships)})${picksText(card.picks)}`);
70
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
72
71
  });
73
72
  const pairs = r.pairs.slice(0, top);
74
73
  if (pairs.length > 0) {
75
74
  lines.push('', 'Best two-card pairs:');
76
75
  pairs.forEach((s, i) => {
77
- lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeUsd(s.net)}`);
76
+ lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
78
77
  if (s.alternatives.length > 0)
79
78
  lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
80
79
  for (const split of splitText(s, name))
@@ -96,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
96
95
  export function formatAdditions(r, catalog, all) {
97
96
  const name = cardNamer(catalog);
98
97
  const lines = [
99
- `Your wallet earns about ${wholeUsd(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeUsd(r.current.annualFees)} in annual fees: ${wholeUsd(r.current.net)} after fees` : ''}.`,
98
+ `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
100
99
  ];
101
100
  for (const split of splitText(r.current, name))
102
101
  lines.push(` ${split}`);
@@ -107,7 +106,7 @@ export function formatAdditions(r, catalog, all) {
107
106
  else {
108
107
  lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
109
108
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
110
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee${membershipText(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
109
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
111
110
  });
112
111
  }
113
112
  const notes = new Set([
@@ -118,6 +117,6 @@ export function formatAdditions(r, catalog, all) {
118
117
  ]);
119
118
  if (notes.size > 0)
120
119
  lines.push('', ...notes);
121
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
120
+ lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
122
121
  return `${lines.join('\n')}\n`;
123
122
  }
@@ -1,11 +1,6 @@
1
- import { feeText, money, shortDate } from "../engine/text.js";
1
+ import { offerTerms } from "../engine/offers.js";
2
+ import { feeText, money, shortDate, wholeDollars } from "../engine/text.js";
2
3
  const TOP = 10;
3
- function reward(o, currencyName) {
4
- const amount = o.dollars !== null
5
- ? money(o.dollars)
6
- : `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
- return o.varies ? `Up to ${amount}` : amount;
8
- }
9
4
  export function formatOffers(c, currencyName, all) {
10
5
  if (c.offers.length === 0) {
11
6
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -15,19 +10,19 @@ export function formatOffers(c, currencyName, all) {
15
10
  const lines = [];
16
11
  shown.forEach((o, i) => {
17
12
  const weeks = (o.months * 52) / 12;
18
- const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
19
- const spend = o.spend > 0
20
- ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
21
- : 'with no spending required';
22
- lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
23
- lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
13
+ const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
14
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
15
+ lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
24
16
  if (o.ineligible)
25
17
  lines.push(` ${o.ineligible}`);
26
18
  if (o.note)
27
19
  lines.push(` ${o.note}`);
28
20
  });
29
- if (c.offers.length > shown.length)
30
- lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
21
+ const ruledOut = c.offers.filter((o) => o.ineligible !== null && !shown.includes(o));
22
+ if (ruledOut.length > 0)
23
+ lines.push(` ${String(ruledOut.length)} more left out as likely not eligible with a card you hold: ${ruledOut.map((o) => o.cardName).join(', ')}.`);
24
+ if (c.offers.length > shown.length + ruledOut.length)
25
+ lines.push(` + ${String(c.offers.length - shown.length - ruledOut.length)} more (--all lists them)`);
31
26
  if (c.withoutOffer.length > 0)
32
27
  lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
33
28
  lines.push('', ...c.notes);
@@ -1,7 +1,7 @@
1
- import { money, pct } from "../engine/text.js";
1
+ import { dollarsAndCents, money, pct } from "../engine/text.js";
2
+ import { noCardText } from "../engine/recommend.js";
2
3
  export const plain = (_format, text) => text;
3
4
  const TOP = 3;
4
- const cents = (n) => `$${n.toFixed(2)}`;
5
5
  const label = (c) => c.checkoutLabel ?? c.cardName;
6
6
  function header(rec, purchase) {
7
7
  const m = rec.merchant;
@@ -30,7 +30,7 @@ function cardLines(cards, style, bonusPick) {
30
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
31
31
  const lines = [];
32
32
  cards.forEach((c) => {
33
- const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
33
+ const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
34
34
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
35
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
36
36
  for (const r of c.reasoning)
@@ -50,9 +50,9 @@ function excludedLines(excluded) {
50
50
  export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
51
51
  const lines = [header(rec, purchase), ''];
52
52
  const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
53
- const noCard = `None of your cards work at ${rec.merchant.resolvedName}.`;
53
+ const noCard = noCardText(rec.merchant.resolvedName);
54
54
  if (rec.decision && top) {
55
- const earned = top.earned === null ? '' : `, about ${cents(top.earned)}`;
55
+ const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
56
56
  const confidence = rec.decision.confidence;
57
57
  const why = rec.bonus ? ' for its welcome bonus' : '';
58
58
  lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${confidence[0]?.toUpperCase() ?? ''}${confidence.slice(1)} confidence.`);
@@ -42,7 +42,10 @@ export function formatWalletSummary(summary) {
42
42
  const memberships = summary.memberships.length > 0
43
43
  ? `Memberships: ${summary.memberships.join(', ')}.`
44
44
  : 'Memberships: none saved.';
45
- return [...cards, memberships].join('\n');
45
+ const values = summary.pointValues.length > 0
46
+ ? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
47
+ : "Point values: earnrate's defaults.";
48
+ return [...cards, memberships, values].join('\n');
46
49
  }
47
50
  export function formatBooking(amount, cardId, cap, bonus) {
48
51
  const messages = [];
@@ -54,15 +54,34 @@ export function makeContext(deps) {
54
54
  }
55
55
  // Cards named in the conversation come first, then the saved wallet; with
56
56
  // neither (null), answers cover every card earnrate knows.
57
+ // The point values and memberships the user saved apply to every answer,
58
+ // not just ones from their saved cards. A wallet that can't be read has
59
+ // none.
60
+ async function saved() {
61
+ try {
62
+ const wallet = await deps.store.load();
63
+ return {
64
+ valuations: wallet?.valuations ?? {},
65
+ memberships: wallet?.memberships ?? [],
66
+ };
67
+ }
68
+ catch {
69
+ return { valuations: {}, memberships: [] };
70
+ }
71
+ }
57
72
  async function cardsFor(cards, memberships) {
58
- if (cards && cards.length > 0)
59
- return walletFromCards(catalog, {
73
+ if (cards && cards.length > 0) {
74
+ const named = walletFromCards(catalog, {
60
75
  cards: cards.map(({ cardId, ...rest }) => ({
61
76
  id: cardId,
62
77
  ...defined(rest),
63
78
  })),
64
- ...(memberships ? { memberships } : {}),
79
+ memberships: memberships ?? (await saved()).memberships,
65
80
  }, deps.today());
81
+ return typeof named === 'string'
82
+ ? named
83
+ : { ...named, valuations: (await saved()).valuations };
84
+ }
66
85
  const read = await readableWallet();
67
86
  if (typeof read === 'string')
68
87
  return read;
@@ -92,5 +111,13 @@ export function makeContext(deps) {
92
111
  };
93
112
  }
94
113
  const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
95
- return { wallet, readableWallet, resolve, cardsFor, changed, cardName };
114
+ return {
115
+ wallet,
116
+ readableWallet,
117
+ resolve,
118
+ cardsFor,
119
+ saved,
120
+ changed,
121
+ cardName,
122
+ };
96
123
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
3
- import { VALUATION_PROFILES } from "../engine/profiles.js";
3
+ import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
4
4
  import { knownMemberships } from "../engine/setup.js";
5
5
  import { IsoDate } from "../schema/common.js";
6
6
  export const Merchant = z
@@ -112,6 +112,8 @@ export function makeInputs(catalog) {
112
112
  .enum(categoryIds)
113
113
  .optional()
114
114
  .describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${categoryList}.`);
115
+ // record_purchase can't skip an unknown merchant's caps, so it asks.
116
+ const RecordCategoryInput = CategoryInput.describe(`For a merchant earnrate doesn't know, its spending category, e.g. dining for a restaurant: without one the purchase isn't recorded, since its caps can't be told. If nobody knows, other records it toward a welcome bonus without touching any cap. Ignored when earnrate knows the merchant. Categories: ${categoryList}.`);
115
117
  const CatalogCategory = z
116
118
  .enum(categoryIds)
117
119
  .describe(`A spending category: ${categoryList}.`);
@@ -128,11 +130,12 @@ export function makeInputs(catalog) {
128
130
  valuation: z
129
131
  .enum(['flat', 'points-heavy'])
130
132
  .optional()
131
- .describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
133
+ .describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
132
134
  };
133
135
  const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
134
136
  return {
135
137
  CategoryInput,
138
+ RecordCategoryInput,
136
139
  CatalogCategory,
137
140
  NamedCards,
138
141
  NamedMemberships,
@@ -141,8 +144,12 @@ export function makeInputs(catalog) {
141
144
  AllMemberships,
142
145
  };
143
146
  }
144
- // The catalog filters as exploreCards and simulate take them.
145
- export const settings = (args) => ({
147
+ // The catalog filters as exploreCards and simulate take them. Memberships or
148
+ // a valuation profile asked for win over the user's saved ones.
149
+ export const settings = (args, saved = {
150
+ valuations: {},
151
+ memberships: [],
152
+ }) => ({
146
153
  filters: {
147
154
  ...ALL_PERSONAL_CARDS,
148
155
  kind: args.kind ?? 'any',
@@ -150,6 +157,8 @@ export const settings = (args) => ({
150
157
  business: args.business ?? false,
151
158
  issuer: args.issuer ?? null,
152
159
  },
153
- memberships: args.memberships ?? [],
154
- valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
160
+ memberships: args.memberships ?? saved.memberships,
161
+ valuations: args.valuation
162
+ ? VALUATION_PROFILES[args.valuation]
163
+ : saved.valuations,
155
164
  });
@@ -78,6 +78,13 @@ const RecommendationSchema = z.object({
78
78
  });
79
79
  export const WalletSummarySchema = z.object({
80
80
  memberships: z.array(z.string()),
81
+ pointValues: z.array(z.object({
82
+ currency: z.string(),
83
+ name: z.string(),
84
+ centsPerUnit: z.number(),
85
+ redemption: z.string().nullable(),
86
+ })),
87
+ started: z.string().nullable(),
81
88
  cards: z.array(z.object({
82
89
  cardId: z.string(),
83
90
  cardName: z.string(),
@@ -99,6 +106,7 @@ export const WalletSummarySchema = z.object({
99
106
  })
100
107
  .nullable(),
101
108
  currency: z.string(),
109
+ currencyName: z.string(),
102
110
  centsPerUnit: z.number(),
103
111
  lastVerified: z.string(),
104
112
  verifiedBy: z.string(),
@@ -23,6 +23,7 @@ export const SAVED_WALLET_TOOLS = [
23
23
  'update_card',
24
24
  'remove_card',
25
25
  'set_memberships',
26
+ 'set_point_values',
26
27
  'export_wallet',
27
28
  'import_wallet',
28
29
  'delete_wallet',
@@ -7,7 +7,7 @@ import { WalletChangeSchema } from "../schemas.js";
7
7
  export function registerAccountTools(server, catalog, store, ctx) {
8
8
  server.registerTool('export_wallet', {
9
9
  title: 'Export the wallet',
10
- description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, cap usage and bonus progress. Never card numbers.',
10
+ 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 and bonus progress. Never card numbers.',
11
11
  inputSchema: z.object({}),
12
12
  outputSchema: z.object({ yaml: z.string() }),
13
13
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -24,6 +24,7 @@ const SOURCES = {
24
24
  category: 'a kind of purchase, not a store',
25
25
  none: 'not recognized',
26
26
  };
27
+ const EVERY_CARD_NOTE = "Picked from every card earnrate knows, as asked, not just the user's saved cards. The user's saved memberships and point values still apply.";
27
28
  const TOP = 10;
28
29
  // The tools that answer without a saved wallet: from every card, the cards
29
30
  // named in the conversation, or a saved wallet if there is one.
@@ -73,14 +74,14 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
73
74
  amount: purchase.amount,
74
75
  channel: purchase.channel,
75
76
  date: purchase.date,
76
- }, settings({ memberships }), resolved.merchant);
77
+ }, settings({ memberships }, await ctx.saved()), resolved.merchant);
77
78
  const allCards = { ...explored, ranked: explored.ranked.slice(0, TOP) };
78
79
  const empty = recommend(catalog, EMPTY_WALLET, purchase, resolved.merchant);
79
80
  return {
80
81
  content: [
81
82
  {
82
83
  type: 'text',
83
- text: `${retry}${formatExploration(allCards, true)}\n${ALL_CARDS_NOTE}`,
84
+ text: `${retry}${formatExploration(allCards, catalog, true)}\n${ALL_CARDS_NOTE}`,
84
85
  },
85
86
  ],
86
87
  structuredContent: {
@@ -155,7 +156,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
155
156
  const wallet = await ctx.cardsFor(args.cards, undefined);
156
157
  if (typeof wallet === 'string')
157
158
  return fail(wallet);
158
- const base = wallet ?? EMPTY_WALLET;
159
+ const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
159
160
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
160
161
  kind: args.kind ?? 'any',
161
162
  noAnnualFee: args.noAnnualFee ?? false,
@@ -164,7 +165,9 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
164
165
  }, deps.today());
165
166
  const trimmed = {
166
167
  ...comparison,
167
- offers: comparison.offers.slice(0, TOP),
168
+ // Offers ruled out by a card the user holds sort last; keep them in
169
+ // view so the reason shows.
170
+ offers: comparison.offers.filter((o, i) => i < TOP || o.ineligible !== null),
168
171
  };
169
172
  const more = comparison.offers.length - trimmed.offers.length;
170
173
  return {
@@ -173,7 +176,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
173
176
  type: 'text',
174
177
  text: formatOffers(trimmed, (id) => currencyName(catalog, id), true) +
175
178
  (more > 0
176
- ? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers. Narrow them with kind, noAnnualFee or business.\n`
179
+ ? `\nThe top ${String(TOP)} of ${String(comparison.offers.length)} offers${trimmed.offers.length > TOP ? `, plus ${String(trimmed.offers.length - TOP)} likely not eligible` : ''}. Narrow them with kind, noAnnualFee or business.\n`
177
180
  : ''),
178
181
  },
179
182
  ],
@@ -196,7 +199,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
196
199
  }),
197
200
  outputSchema: ExplorationSchema,
198
201
  annotations: { readOnlyHint: true, openWorldHint: false },
199
- }, (args) => {
202
+ }, async (args) => {
200
203
  if (args.merchant === undefined && args.category === undefined)
201
204
  return fail('Give a merchant or a category.');
202
205
  const result = exploreCards(catalog, {
@@ -205,10 +208,12 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
205
208
  amount: args.amount ?? null,
206
209
  channel: args.channel ?? null,
207
210
  date: deps.today(),
208
- }, settings(args));
211
+ }, settings(args, await ctx.saved()));
209
212
  const trimmed = { ...result, ranked: result.ranked.slice(0, TOP) };
210
213
  return {
211
- content: [{ type: 'text', text: formatExploration(trimmed, true) }],
214
+ content: [
215
+ { type: 'text', text: formatExploration(trimmed, catalog, true) },
216
+ ],
212
217
  structuredContent: trimmed,
213
218
  };
214
219
  });
@@ -248,7 +253,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
248
253
  if (unknown.length > 0)
249
254
  return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
250
255
  const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
251
- const options = { ...settings(args), start: deps.today() };
256
+ const options = {
257
+ ...settings(args, await ctx.saved()),
258
+ start: deps.today(),
259
+ };
252
260
  const wantsTheirs = (args.cards !== undefined && args.cards.length > 0) ||
253
261
  args.compareToWallet === true;
254
262
  const wallet = wantsTheirs
@@ -296,6 +304,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
296
304
  inputSchema: z.object({
297
305
  date: DateInput,
298
306
  cards: NamedCards,
307
+ everyCard: z
308
+ .boolean()
309
+ .optional()
310
+ .describe('true to pick from every card earnrate knows even when the user has a saved wallet ("which cards should I get?"); the filters then apply.'),
299
311
  memberships: NamedMemberships,
300
312
  kind: CatalogFilters.kind,
301
313
  noAnnualFee: CatalogFilters.noAnnualFee,
@@ -306,14 +318,18 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
306
318
  outputSchema: CheatSheetSchema,
307
319
  annotations: { readOnlyHint: true, openWorldHint: false },
308
320
  }, async (args) => {
309
- const wallet = await ctx.cardsFor(args.cards, args.memberships);
321
+ const wallet = args.everyCard === true
322
+ ? null
323
+ : await ctx.cardsFor(args.cards, args.memberships);
310
324
  if (typeof wallet === 'string')
311
325
  return fail(wallet);
312
326
  const date = args.date ?? deps.today();
313
327
  const sheet = wallet === null
314
328
  ? {
315
- ...catalogCheatSheet(catalog, date, settings(args)),
316
- notes: [ALL_CARDS_NOTE],
329
+ ...catalogCheatSheet(catalog, date, settings(args, await ctx.saved())),
330
+ notes: [
331
+ args.everyCard === true ? EVERY_CARD_NOTE : ALL_CARDS_NOTE,
332
+ ],
317
333
  }
318
334
  : cheatSheet(catalog, args.valuation
319
335
  ? withValuationProfile(wallet, args.valuation)
@@ -1,12 +1,27 @@
1
1
  import { z } from 'zod';
2
2
  import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
- import { choiceName, money, words } from "../../engine/text.js";
3
+ import { choiceName, currencyLabel, money, shortDate, words, } from "../../engine/text.js";
4
+ import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
4
5
  import { walletSummary } from "../../engine/wallet-summary.js";
5
6
  import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
6
7
  import { fail } from "../context.js";
7
8
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
8
9
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
9
10
  const WRITE = { readOnlyHint: false, openWorldHint: false };
11
+ // "Saved wallet started 4 minutes ago": relative, since the server doesn't
12
+ // know the user's time zone, and precise enough to tell apart two wallets
13
+ // started the same day.
14
+ function startedText(started, now) {
15
+ const minutes = Math.floor((now - Date.parse(started)) / 60_000);
16
+ const ago = minutes < 1
17
+ ? 'just now'
18
+ : minutes < 60
19
+ ? `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`
20
+ : minutes < 48 * 60
21
+ ? `${String(Math.floor(minutes / 60))} hour${minutes < 120 ? '' : 's'} ago`
22
+ : `on ${shortDate(started.slice(0, 10))}`;
23
+ return `Saved wallet started ${ago}.`;
24
+ }
10
25
  // What update_card changed, so the reply confirms the values it saved.
11
26
  function changes(args) {
12
27
  const out = [];
@@ -35,17 +50,25 @@ export function registerWalletTools(server, deps, ctx, inputs) {
35
50
  const { catalog, store } = deps;
36
51
  server.registerTool('list_wallet', {
37
52
  title: 'List wallet',
38
- description: "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, which stores a store card works at, and when each card's data was last verified.",
53
+ description: "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, 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.",
39
54
  outputSchema: WalletSummarySchema,
40
55
  annotations: { readOnlyHint: true, openWorldHint: false },
41
56
  }, async () => {
42
57
  const read = await ctx.readableWallet();
43
58
  if (typeof read === 'string')
44
59
  return fail(read);
45
- const summary = walletSummary(catalog, read.wallet, deps.today());
60
+ const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
61
+ const summary = {
62
+ ...walletSummary(catalog, read.wallet, deps.today()),
63
+ started,
64
+ };
65
+ const when = started === null ? '' : `${startedText(started, Date.now())}\n`;
46
66
  return {
47
67
  content: [
48
- { type: 'text', text: read.start ?? formatWalletSummary(summary) },
68
+ {
69
+ type: 'text',
70
+ text: `${when}${read.start ?? formatWalletSummary(summary)}`,
71
+ },
49
72
  ],
50
73
  structuredContent: summary,
51
74
  };
@@ -62,7 +85,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
62
85
  amount: z.number().positive().describe('Amount spent in US dollars.'),
63
86
  channel: ChannelInput,
64
87
  date: DateInput,
65
- category: inputs.CategoryInput,
88
+ category: inputs.RecordCategoryInput,
66
89
  }),
67
90
  outputSchema: RecordResultSchema,
68
91
  annotations: {
@@ -167,4 +190,34 @@ export function registerWalletTools(server, deps, ctx, inputs) {
167
190
  ? `Memberships set to ${memberships.join(', ')}.`
168
191
  : '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
192
  });
193
+ server.registerTool('set_point_values', {
194
+ title: 'Set point values',
195
+ 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. It needs a saved wallet with a card in it. 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: ${catalog.currencies.map((c) => c.id).join(', ')}.`,
196
+ inputSchema: z.object({
197
+ values: z
198
+ .record(z.string(), z.number().nullable())
199
+ .optional()
200
+ .describe('Cents per point by currency id, e.g. {"UR": 1.5, "MR": 1.6}. null goes back to the default.'),
201
+ profile: z
202
+ .enum(['flat', 'points-heavy'])
203
+ .optional()
204
+ .describe(`A preset: flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}). Values given too win over it.`),
205
+ }),
206
+ outputSchema: WalletChangeSchema,
207
+ annotations: { ...WRITE, destructiveHint: true, idempotentHint: true },
208
+ }, async ({ values, profile }) => {
209
+ const changes = {
210
+ ...(profile ? VALUATION_PROFILES[profile] : {}),
211
+ ...(values ?? {}),
212
+ };
213
+ if (Object.keys(changes).length === 0)
214
+ return fail('Give values (cents per point by currency) or a profile.');
215
+ const result = await store.setValuations(changes);
216
+ const set = Object.entries(changes).map(([id, cents]) => cents === null
217
+ ? `${currencyLabel(catalog, id)} back to the default`
218
+ : `${currencyLabel(catalog, id)} at ${String(cents)}¢`);
219
+ return ctx.changed(result, `Point values: ${set.join(', ')}.`, {
220
+ notes: false,
221
+ });
222
+ });
170
223
  }
@@ -146,17 +146,10 @@ export const CardSchema = z
146
146
  // When the first year differs, usually a waived fee.
147
147
  first_year_fee: z.number().nonnegative().optional(),
148
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(),
149
+ // A paid membership (data/memberships.yaml) the card can't be had
150
+ // without, Costco's for Costco Anywhere. Its fee counts like an annual
151
+ // fee for anyone not already paying it.
152
+ requires_membership: KebabId.optional(),
160
153
  pools_into: z
161
154
  .strictObject({
162
155
  currency: CurrencyId,
@@ -9,5 +9,9 @@ export const CategorySchema = z.strictObject({
9
9
  // Everyday words people search by ("groceries"), besides the name.
10
10
  aliases: z.array(z.string().min(1)).default([]),
11
11
  note: z.string().optional(),
12
+ // Bought as a service, a category reads "on" (3x on flights); places read
13
+ // "at". Subcategories inherit it.
14
+ preposition: z.literal('on').optional(),
15
+ slug: z.string().optional(), // its search page, when not from its name
12
16
  });
13
17
  export const CategoriesFileSchema = z.array(CategorySchema).min(1);
@@ -9,6 +9,8 @@ export const CurrencySchema = z.strictObject({
9
9
  // Value per redemption option, for currencies whose worth depends on how
10
10
  // they're redeemed. A wallet picks one by key.
11
11
  redemptions: z.record(z.string(), z.number().positive()).optional(),
12
+ // How each redemption reads after "redeemed": "into a SoFi account".
13
+ redemption_names: z.record(z.string(), z.string()).optional(),
12
14
  sources: z.array(z.url()).default([]),
13
15
  note: z.string().optional(),
14
16
  });
@@ -0,0 +1,15 @@
1
+ import { z } from 'zod';
2
+ import { KebabId } from "./common.js";
3
+ export const MembershipSchema = z.strictObject({
4
+ id: KebabId,
5
+ name: z.string().min(1), // "Costco membership"
6
+ // The site's checkbox for it, when it's one most people would know.
7
+ ask: z.string().min(1).optional(),
8
+ // Memberships in one group are tiers: a person holds one at a time.
9
+ group: KebabId.optional(),
10
+ // Dollars a year, for a membership a card needs (`requires_membership`).
11
+ annual_fee: z.number().nonnegative().optional(),
12
+ // Merchants only members can buy from: spending there means a member.
13
+ members_shop_at: z.array(KebabId).default([]),
14
+ });
15
+ export const MembershipsFileSchema = z.array(MembershipSchema).min(1);
@@ -27,6 +27,10 @@ export const MerchantSchema = z.strictObject({
27
27
  // Other ways to buy the same thing, e.g. through a card's travel site, that
28
28
  // can earn more. Answers mention them when they would.
29
29
  booked_via: z.array(KebabId).default([]),
30
+ // A card issuer's or store's travel or ticketing site (portal: Amex Travel,
31
+ // Costco Travel), or a booking made through one (booking: "Hotel through
32
+ // Amex Travel").
33
+ kind: z.enum(['portal', 'booking']).optional(),
30
34
  note: z.string().optional(),
31
35
  });
32
36
  export const MerchantsFileSchema = z.array(MerchantSchema);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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",