earnrate 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +28 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
  5. package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
  6. package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
  7. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  8. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  9. package/data/cards/discover-it-cash-back.yaml +9 -6
  10. package/data/cards/venmo-credit-card.yaml +1 -1
  11. package/data/categories.yaml +10 -0
  12. package/data/merchants/seed.yaml +3 -1
  13. package/dist/cli/import.js +14 -11
  14. package/dist/cli/todo.js +3 -2
  15. package/dist/cli/wallet.js +50 -17
  16. package/dist/engine/cheat-sheet.js +15 -11
  17. package/dist/engine/checklist.js +23 -25
  18. package/dist/engine/credits.js +2 -2
  19. package/dist/engine/evaluate.js +1 -1
  20. package/dist/engine/explore.js +4 -4
  21. package/dist/engine/intro.js +1 -5
  22. package/dist/engine/memberships.js +2 -6
  23. package/dist/engine/offers.js +3 -5
  24. package/dist/engine/periods.js +5 -0
  25. package/dist/engine/profiles.js +2 -1
  26. package/dist/engine/recommend.js +46 -33
  27. package/dist/engine/record.js +2 -1
  28. package/dist/engine/rotation.js +2 -1
  29. package/dist/engine/rule-text.js +98 -33
  30. package/dist/engine/setup.js +7 -3
  31. package/dist/engine/simulate.js +4 -4
  32. package/dist/engine/text.js +29 -8
  33. package/dist/engine/wallet-change.js +5 -5
  34. package/dist/engine/wallet-summary.js +24 -21
  35. package/dist/format/card-text.js +2 -3
  36. package/dist/format/cards.js +7 -3
  37. package/dist/format/explore.js +2 -2
  38. package/dist/format/wallet-yaml.js +13 -17
  39. package/dist/format/wallet.js +14 -10
  40. package/dist/import/analyze.js +13 -1
  41. package/dist/mcp/context.js +2 -3
  42. package/dist/mcp/inputs.js +3 -3
  43. package/dist/mcp/schemas.js +2 -0
  44. package/dist/mcp/server.js +0 -2
  45. package/dist/mcp/tools/account.js +1 -1
  46. package/dist/mcp/tools/answers.js +10 -5
  47. package/dist/mcp/tools/wallet.js +10 -6
  48. package/dist/schema/card.js +6 -10
  49. package/dist/schema/merchant.js +3 -0
  50. package/package.json +2 -1
@@ -4,12 +4,12 @@ import { introUntil, introWindows, openedOn } from "./intro.js";
4
4
  import { membershipName } from "./memberships.js";
5
5
  import { milestoneSummaries } from "./milestones.js";
6
6
  import { creditStates } from "./credits.js";
7
- import { creditWhere, optionName, ruleParts } from "./rule-text.js";
7
+ import { creditWhere, optionName, ruleParts, rulePreposition, } from "./rule-text.js";
8
8
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
9
9
  import { STALE_AFTER_DAYS } from "./recommend.js";
10
10
  import { rotationGap } from "./rotation.js";
11
11
  import { activationRules } from "./setup.js";
12
- import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
12
+ import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
13
13
  import { cardValuation, currencyValuation } from "./valuation.js";
14
14
  const matchKeys = (rule) => rule.match.choice !== undefined
15
15
  ? []
@@ -30,11 +30,9 @@ function capLimit(card, shown, rule) {
30
30
  shown.filter((r) => r.cap === rule.cap).length > 1;
31
31
  return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
32
32
  }
33
- // Every bonus rate the cardholder gets now, best first, so none reads as the
34
- // base rate. Rates behind a membership the wallet lacks are left out, and so
35
- // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
36
- // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
37
- // a line.
33
+ // Best first. Leaves out rates behind a membership the wallet lacks and a
34
+ // lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
35
+ // without Prime, once Prime is held). Rules at the same rate share a line.
38
36
  function headlineRates(catalog, wallet, card, held, currency, today) {
39
37
  const shown = card.rules
40
38
  .filter((r) => inEffect(r, today) &&
@@ -65,22 +63,28 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
65
63
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
66
64
  const payment = r.requires?.payment;
67
65
  const key = [
68
- `${earnText(catalog, r.rate, currency)} on`,
66
+ earnText(catalog, r.rate, currency),
69
67
  capLimit(card, shown, r),
70
68
  pending ? ', once activated' : '',
71
69
  payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
72
70
  ].join('|');
73
- const whats = lines.get(key) ?? [];
74
- for (const part of ruleParts(catalog, r))
71
+ const line = lines.get(key) ?? { at: [], on: [] };
72
+ const whats = line[rulePreposition(catalog, [r])];
73
+ for (const part of ruleParts(catalog, r, card))
75
74
  if (!whats.includes(part))
76
75
  whats.push(part);
77
- lines.set(key, whats);
76
+ lines.set(key, line);
78
77
  }
79
78
  return [
80
- ...[...lines].map(([key, whats]) => {
79
+ ...[...lines].map(([key, { at, on }]) => {
81
80
  const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
82
81
  const after = `${limit}${activate}`;
83
- return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
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(/^, /, '')})`}`;
84
88
  }),
85
89
  ...(shown.some((r) => r.match.any &&
86
90
  r.geography !== 'foreign_only' &&
@@ -103,13 +107,11 @@ export function activationsDue(catalog, card, held, today) {
103
107
  seen.add(g);
104
108
  const rules = card.rules.filter((r) => group.includes(r.id));
105
109
  const first = rules[0];
106
- const categories = rules.flatMap((r) => r.match.categories ?? []);
107
- const servicesOnly = categories.length > 0 &&
108
- rules.every((r) => (r.match.merchants ?? []).length === 0) &&
109
- categories.every((c) => categoryReadsOn(catalog, c));
110
110
  out.push({
111
- what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
112
- preposition: servicesOnly ? 'on' : 'at',
111
+ what: listed([
112
+ ...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
113
+ ]),
114
+ preposition: rulePreposition(catalog, rules),
113
115
  from: first?.valid_from ?? null,
114
116
  to: first?.valid_to ?? null,
115
117
  activateBy: first?.activate_by ?? null,
@@ -154,8 +156,9 @@ function capSummaries(catalog, card, held, today) {
154
156
  const shared = card.caps.find((c) => c.id === key);
155
157
  const label = shared?.rotating
156
158
  ? 'the rotating bonus categories'
157
- : listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
158
- words(key);
159
+ : listed([
160
+ ...new Set(covered.flatMap((r) => ruleParts(catalog, r, card))),
161
+ ]) || words(key);
159
162
  return {
160
163
  key,
161
164
  label,
@@ -1,8 +1,7 @@
1
- import { earnText } from "../engine/text.js";
2
1
  // Card names, fees and requirements as earnrate.dev and its static search
3
2
  // pages show them. Node-free: the site bundles it.
4
3
  import { membershipName, membershipText } from "../engine/memberships.js";
5
- import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
4
+ import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
6
5
  // An issuer's name as people know it, from data/issuers.yaml.
7
6
  export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
8
7
  export function cardText(catalog) {
@@ -42,7 +41,7 @@ export function cardText(catalog) {
42
41
  if (until !== null)
43
42
  lines.push(`This rate runs through ${until}`);
44
43
  if (r.cap)
45
- lines.push(`Up to ${wholeDollars(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
44
+ lines.push(capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency)));
46
45
  return lines;
47
46
  }
48
47
  const memberName = (id) => membershipName(catalog, id);
@@ -1,10 +1,11 @@
1
+ import { membershipName } from "../engine/memberships.js";
1
2
  import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
2
- export function formatCardInfo(card) {
3
+ export function formatCardInfo(catalog, card) {
3
4
  const fee = card.annualFee === null
4
5
  ? ''
5
6
  : card.annualFee === 0
6
7
  ? ', no annual fee'
7
- : `, ${money(card.annualFee)} a year`;
8
+ : `, ${money(card.annualFee)} a year${card.firstYearFee === null ? '' : `, ${card.firstYearFee === 0 ? 'waived' : money(card.firstYearFee)} the first year`}`;
8
9
  const needs = [
9
10
  ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
10
11
  ...(card.requiresMembership
@@ -13,7 +14,9 @@ export function formatCardInfo(card) {
13
14
  ]
14
15
  : []),
15
16
  ...(card.memberships.length > 0
16
- ? [`rates that need a membership: ${card.memberships.join(', ')}`]
17
+ ? [
18
+ `rates that need a membership: ${card.memberships.map((id) => `${membershipName(catalog, id)} (membership id ${id})`).join(', ')}`,
19
+ ]
17
20
  : []),
18
21
  ...(card.activation.length > 0
19
22
  ? [`activate with the issuer: ${card.activation.join(', ')}`]
@@ -21,6 +24,7 @@ export function formatCardInfo(card) {
21
24
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
22
25
  ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
23
26
  ...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
27
+ ...card.credits.map((c) => `statement credit ${c.id}: ${c.text}`),
24
28
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
25
29
  foreignFeeText(card.foreignTransactionFee).toLowerCase(),
26
30
  ];
@@ -1,5 +1,5 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
@@ -51,7 +51,7 @@ export function formatExploration(r, catalog, all) {
51
51
  return `${lines.join('\n')}\n`;
52
52
  }
53
53
  function picksText(picks) {
54
- const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
54
+ const parts = Object.entries(picks).map(([choice, options]) => `${listed(options.map(words))} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
55
55
  return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
56
56
  }
57
57
  function cardNamer(catalog) {
@@ -1,9 +1,18 @@
1
1
  import { Document, parse, visit } from 'yaml';
2
2
  import { walletProblems } from "../data/integrity.js";
3
3
  import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
4
- import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
+ import { WALLET_VERSION, WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
5
5
  // A wallet as the YAML file the CLI reads, for moving it between the
6
6
  // website, the remote server and the command line. Node-free.
7
+ // Every field the schema defines, in its order, so a new wallet field is
8
+ // exported without anyone remembering to add it here.
9
+ const CARD_FIELDS = Object.keys(WalletCardSchema.shape);
10
+ const isEmpty = (value) => value === undefined ||
11
+ (Array.isArray(value)
12
+ ? value.length === 0
13
+ : typeof value === 'object' &&
14
+ value !== null &&
15
+ Object.keys(value).length === 0);
7
16
  function compact(wallet) {
8
17
  return {
9
18
  version: wallet.version ?? WALLET_VERSION,
@@ -13,24 +22,11 @@ function compact(wallet) {
13
22
  ...(wallet.memberships.length > 0
14
23
  ? { memberships: wallet.memberships }
15
24
  : {}),
16
- cards: wallet.cards.map((c) => ({
17
- id: c.id,
18
- ...(c.checkout_label === undefined
19
- ? {}
20
- : { checkout_label: c.checkout_label }),
21
- ...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
22
- ...(c.opened === undefined ? {} : { opened: c.opened }),
23
- ...(Object.keys(c.cap_usage).length > 0
24
- ? { cap_usage: c.cap_usage }
25
- : {}),
26
- ...(c.chosen === undefined ? {} : { chosen: c.chosen }),
27
- ...(c.activated.length > 0 ? { activated: c.activated } : {}),
28
- ...(c.bonus === undefined ? {} : { bonus: c.bonus }),
29
- })),
25
+ cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
30
26
  };
31
27
  }
32
- // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
33
- // date, not a string.
28
+ // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare
29
+ // 2026-10-15 as a date, not a string.
34
30
  function quotedYaml(value) {
35
31
  const doc = new Document(value);
36
32
  visit(doc, {
@@ -1,4 +1,4 @@
1
- import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, } from "../engine/text.js";
1
+ import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, possessive, } from "../engine/text.js";
2
2
  // "To do now", above the card details; nothing when all's done.
3
3
  export function formatChecklist(items) {
4
4
  return items.length === 0
@@ -48,10 +48,10 @@ export function formatWalletSummary(summary) {
48
48
  else if (c.intro?.state === 'no-date')
49
49
  lines.push(` Has ${introAdjective(c.intro.days)} rates for new accounts; give its opening date if it opened ${introSince(c.intro.days)}.`);
50
50
  else if (c.opened !== null)
51
- lines.push(` Opened ${c.opened}.`);
51
+ lines.push(` Opened ${shortDate(c.opened)}.`);
52
52
  const b = c.bonus;
53
53
  if (b?.state === 'open' && b.spend !== null)
54
- lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
54
+ lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline === null ? 'by its deadline' : shortDate(b.deadline)}.`);
55
55
  else if (b)
56
56
  lines.push(` Welcome bonus: ${b.state}.`);
57
57
  return lines.join('\n');
@@ -64,29 +64,33 @@ export function formatWalletSummary(summary) {
64
64
  : "Point values: earnrate's defaults.";
65
65
  return [...cards, memberships, values].join('\n');
66
66
  }
67
- export function formatBooking(amount, cardId, cap, bonus, milestones = [], credit = null) {
67
+ export function formatBooking(amount, name, // the card's short name
68
+ cap, bonus, milestones = [], credit = null, capLabel = null) {
68
69
  const messages = [];
69
70
  if (credit)
70
- messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${cardId}'s ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
71
+ messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
71
72
  if (cap) {
72
73
  const left = Math.max(0, cap.capAmount - cap.after);
73
- messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
74
+ messages.push(`Recorded ${money(amount)} on ${name} toward its ${capLabel ?? cap.capKey} cap: ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
74
75
  }
75
76
  if (bonus) {
76
77
  const left = Math.max(0, bonus.spend - bonus.after);
77
78
  messages.push(left === 0
78
- ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
79
- : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
79
+ ? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
80
+ : `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline === null ? 'the deadline' : shortDate(bonus.deadline)}.`);
80
81
  }
81
82
  // Every milestone counts the same purchases, so one line covers them.
82
83
  const first = milestones[0];
83
84
  if (first) {
84
85
  const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
85
86
  const next = milestones.find((m) => m.after < m.spend);
87
+ const period = first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year';
86
88
  messages.push([
87
- `Spend on ${cardId} this ${first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year'}: ${money(first.after)}.`,
89
+ first.started
90
+ ? `Started tracking spend on ${name} this ${period} with this purchase: ${money(first.after)}. Spending on it earlier this ${period} isn't counted; give the total so far (milestoneSpent) to include it.`
91
+ : `Spend on ${name} this ${period}: ${money(first.after)}.`,
88
92
  ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
89
- ...(next
93
+ ...(next && !first.started
90
94
  ? [
91
95
  `${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
92
96
  ]
@@ -4,7 +4,7 @@ import { capByKey, categoryMatches } from "../engine/evaluate.js";
4
4
  import { milestoneProgress } from "../engine/milestones.js";
5
5
  import { periodKey } from "../engine/periods.js";
6
6
  import { recommend } from "../engine/recommend.js";
7
- import { applyBooking, capBooking } from "../engine/record.js";
7
+ import { applyBooking, applyCreditBooking, capBooking, creditBooking, } from "../engine/record.js";
8
8
  import { roundCents } from "../engine/text.js";
9
9
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
10
10
  import { cleanDescriptor } from "./statement.js";
@@ -55,6 +55,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
55
55
  const missed = new Map();
56
56
  const coding = new Map();
57
57
  const bookings = [];
58
+ const credits = [];
58
59
  let outsideCurrentPeriod = 0;
59
60
  let alreadyImported = 0;
60
61
  let purchases = 0;
@@ -145,6 +146,16 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
145
146
  outsideCurrentPeriod++;
146
147
  }
147
148
  }
149
+ if (line.amount > 0 && !alreadyRecorded(entry, 'credit')) {
150
+ const credit = creditBooking(catalog, current, recorded, merchant);
151
+ const held = current.cards.find((c) => c.id === cardId);
152
+ if (credit &&
153
+ periodKey(credit.periodKind, today, held?.anniversary ?? null) ===
154
+ credit.period) {
155
+ credits.push({ ...entry, booking: credit });
156
+ current = applyCreditBooking(current, credit);
157
+ }
158
+ }
148
159
  if (line.amount < 0) {
149
160
  refunds += -line.amount;
150
161
  continue;
@@ -244,6 +255,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
244
255
  .sort((a, b) => b.best - b.earned - (a.best - a.earned)),
245
256
  coding: [...coding.values()].sort((a, b) => b.count - a.count),
246
257
  bookings,
258
+ credits,
247
259
  outsideCurrentPeriod,
248
260
  alreadyImported,
249
261
  bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
@@ -72,9 +72,8 @@ export function makeContext(deps) {
72
72
  }
73
73
  // Cards named in the conversation come first, then the saved wallet; with
74
74
  // neither (null), answers cover every card earnrate knows.
75
- // The point values and memberships the user saved apply to every answer,
76
- // not just ones from their saved cards. A wallet that can't be read has
77
- // none.
75
+ // Saved point values and memberships apply to every answer, not just ones
76
+ // from saved cards. A wallet that can't be read has none.
78
77
  async function saved() {
79
78
  try {
80
79
  const wallet = await deps.store.load();
@@ -80,15 +80,15 @@ const cardFields = {
80
80
  milestoneSpent: z
81
81
  .record(z.string(), z.number().nonnegative())
82
82
  .optional()
83
- .describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
83
+ .describe('Dollars already spent on the card this year, for its spend bonuses (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
84
84
  enrolled: z
85
85
  .array(z.string())
86
86
  .optional()
87
- .describe('Merchant credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
87
+ .describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
88
88
  creditUsed: z
89
89
  .record(z.string(), z.number().nonnegative())
90
90
  .optional()
91
- .describe('Dollars of a merchant credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
91
+ .describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
92
92
  bonus: z
93
93
  .object({
94
94
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
@@ -245,6 +245,7 @@ export const RecordResultSchema = z.object({
245
245
  spend: z.number(),
246
246
  before: z.number(),
247
247
  after: z.number(),
248
+ started: z.boolean(),
248
249
  })),
249
250
  credit: z
250
251
  .object({
@@ -297,6 +298,7 @@ export const CardSearchSchema = z.object({
297
298
  network: CardNetwork,
298
299
  currency: z.string(),
299
300
  annualFee: z.number().nullable(),
301
+ firstYearFee: z.number().nullable(),
300
302
  foreignTransactionFee: z.number().nullable(),
301
303
  business: z.boolean(),
302
304
  onlyAt: z.array(z.string()).nullable(),
@@ -6,7 +6,6 @@ import { registerPrompts } from "./prompts.js";
6
6
  import { registerAccountTools } from "./tools/account.js";
7
7
  import { registerAnswerTools } from "./tools/answers.js";
8
8
  import { registerWalletTools } from "./tools/wallet.js";
9
- // The tools that answer without a saved wallet.
10
9
  export const ANSWER_TOOLS = [
11
10
  'best_card_for_purchase',
12
11
  'best_cards_for',
@@ -16,7 +15,6 @@ export const ANSWER_TOOLS = [
16
15
  'resolve_merchant',
17
16
  'search_cards',
18
17
  ];
19
- // The tools that read or change a saved wallet.
20
18
  export const SAVED_WALLET_TOOLS = [
21
19
  'list_wallet',
22
20
  'record_purchase',
@@ -20,7 +20,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
20
20
  ];
21
21
  server.registerTool('export_wallet', {
22
22
  title: 'Export the wallet',
23
- description: 'The saved wallet as a wallet.yaml file the earnrate command line and earnrate.dev can import: card ids, picks, activations, memberships, point values, cap usage, bonus progress and yearly spending toward spend bonuses. Never card numbers.',
23
+ description: 'The saved wallet as a wallet.yaml file: card ids, checkout labels, picks, activations, opening dates and anniversaries, memberships, point values, cap usage, welcome-bonus progress, yearly spending toward spend bonuses, and statement credits enrolled in and used. earnrate.dev imports it, and the earnrate command line reads it saved as ~/.earnrate/wallet.yaml. Never card numbers.',
24
24
  inputSchema: z.object({}),
25
25
  outputSchema: z.object({ yaml: z.string() }),
26
26
  annotations: {
@@ -34,7 +34,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
34
34
  const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
35
35
  server.registerTool('best_card_for_purchase', {
36
36
  title: 'Best card for a purchase',
37
- description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A merchant statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
37
+ description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
38
38
  inputSchema: z.object({
39
39
  merchant: Merchant,
40
40
  amount: z
@@ -166,6 +166,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
166
  .optional()
167
167
  .describe('Include cards already in the wallet.'),
168
168
  cards: NamedCards,
169
+ memberships: NamedMemberships,
169
170
  }),
170
171
  outputSchema: OfferComparisonSchema,
171
172
  annotations: {
@@ -175,10 +176,13 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
175
176
  openWorldHint: false,
176
177
  },
177
178
  }, async (args) => {
178
- const wallet = await ctx.cardsFor(args.cards, undefined);
179
+ const wallet = await ctx.cardsFor(args.cards, args.memberships);
179
180
  if (typeof wallet === 'string')
180
181
  return fail(wallet);
181
- const base = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
182
+ const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
183
+ const base = args.memberships
184
+ ? { ...found, memberships: args.memberships }
185
+ : found;
182
186
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
183
187
  kind: args.kind ?? 'any',
184
188
  noAnnualFee: args.noAnnualFee ?? false,
@@ -391,7 +395,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
395
  });
392
396
  server.registerTool('search_cards', {
393
397
  title: 'Search cards',
394
- description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
398
+ description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee (and any first-year waiver), foreign transaction fee, whether it\'s already in the wallet, what it needs set up (category picks and their options, memberships, activations), its yearly spend bonuses, first-year rates and statement credits, each with the id update_card takes. Leave the query out to list every card. US cards only.',
395
399
  inputSchema: z.object({
396
400
  query: z
397
401
  .string()
@@ -417,7 +421,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
417
421
  content: [
418
422
  {
419
423
  type: 'text',
420
- text: header + result.cards.map(formatCardInfo).join('\n'),
424
+ text: header +
425
+ result.cards.map((c) => formatCardInfo(catalog, c)).join('\n'),
421
426
  },
422
427
  ],
423
428
  structuredContent: result,
@@ -63,12 +63,11 @@ function changes(catalog, args) {
63
63
  out.push(`welcome bonus tracked from ${args.bonus.opened}, ${money(args.bonus.spent ?? 0)} spent so far`);
64
64
  return out;
65
65
  }
66
- // The tools that read or change a saved wallet.
67
66
  export function registerWalletTools(server, deps, ctx, inputs) {
68
67
  const { catalog, store } = deps;
69
68
  server.registerTool('list_wallet', {
70
69
  title: 'List wallet',
71
- description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, merchant credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, merchant statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
70
+ description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
72
71
  outputSchema: WalletSummarySchema,
73
72
  annotations: {
74
73
  title: 'List wallet',
@@ -102,7 +101,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
102
101
  });
103
102
  server.registerTool('record_purchase', {
104
103
  title: 'Record a purchase',
105
- description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend milestones and merchant credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend milestone and no enrolled merchant credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
104
+ description: `Records a purchase the user has already made, so spending caps, welcome-bonus progress, yearly spend bonuses and statement credits used stay accurate for future recommendations. It updates the user's saved wallet. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap, no open welcome bonus, no spend bonus and no enrolled statement credit are acknowledged and nothing is written. For a merchant earnrate doesn't recognize it asks for category first, so caps aren't missed. ${ctx.walletUse}`,
106
105
  inputSchema: z.object({
107
106
  cardId: z
108
107
  .string()
@@ -142,6 +141,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
142
141
  if (!result.ok)
143
142
  return fail(`Couldn't record it: ${result.error}`);
144
143
  const { cap: booking, bonus, milestones, credit } = result;
144
+ const capLabel = booking
145
+ ? (walletSummary(catalog, w, purchase.date)
146
+ .cards.find((c) => c.cardId === cardId)
147
+ ?.caps.find((c) => c.key === booking.capKey)?.label ?? null)
148
+ : null;
145
149
  const output = booking || bonus || milestones.length > 0 || credit
146
150
  ? {
147
151
  recorded: true,
@@ -149,7 +153,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
149
153
  bonus,
150
154
  milestones,
151
155
  credit,
152
- message: `${formatBooking(amount, cardId, booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
156
+ message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit, capLabel)} Saved in ${ctx.walletName}.`,
153
157
  }
154
158
  : {
155
159
  recorded: false,
@@ -157,7 +161,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
157
161
  bonus: null,
158
162
  milestones: [],
159
163
  credit: null,
160
- message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus, spend milestone or enrolled merchant credit for this purchase, so the wallet wasn't changed.`,
164
+ message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend bonus or enrolled statement credit for this purchase, so the wallet wasn't changed.`,
161
165
  };
162
166
  return {
163
167
  content: [
@@ -191,7 +195,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
191
195
  });
192
196
  server.registerTool('update_card', {
193
197
  title: 'Update a card',
194
- description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend milestones (milestoneSpent), merchant credits enrolled in (enrolled) and used this period (creditUsed), or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
198
+ description: `Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary, opening date (optional; for first-year rates), activated categories, cap spending so far (capSpent), spending this year toward its spend bonuses (milestoneSpent), statement credits enrolled in (enrolled) and used this period (creditUsed), or a welcome bonus to track. Picks replace the saved ones in each choice group you set (other groups are kept) for the current period. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+. ${ctx.walletUse}`,
195
199
  inputSchema: CardChange,
196
200
  outputSchema: WalletChangeSchema,
197
201
  annotations: {
@@ -157,11 +157,9 @@ export const ChoiceSchema = z.strictObject({
157
157
  })),
158
158
  ])),
159
159
  });
160
- // A rate for a stretch after the account opens, counted from the opening
161
- // day: Discover's Cashback Match doubles every rate for the first 365 days,
162
- // Customized Cash adds 3% to its choice category. With `rules`, it changes
163
- // only those rules' rates, inside their caps; past a cap they earn what they
164
- // always do. Without, it changes every rate on the card, past caps included.
160
+ // Counted from the opening day. With `rules`, it changes only those rules'
161
+ // rates, inside their caps; past a cap they earn what they always do.
162
+ // Without, it changes every rate on the card, past caps included.
165
163
  export const IntroRateSchema = z
166
164
  .strictObject({
167
165
  id: KebabId,
@@ -172,11 +170,9 @@ export const IntroRateSchema = z
172
170
  note: z.string().optional(),
173
171
  })
174
172
  .refine((i) => (i.multiply === undefined) !== (i.add === undefined), 'an intro rate needs multiply or add, not both');
175
- // Merchant statement credits: dollars back for spending at named merchants
176
- // (or a category) up to `amount` each period, like Amex Platinum's $75 a
177
- // quarter at lululemon. They're separate from earn rates: the purchase still
178
- // earns the card's rate on its whole amount, and the credit comes on top.
179
- // `half_year` runs January to June and July to December.
173
+ // Separate from earn rates: the purchase still earns the card's rate on its
174
+ // whole amount, and the credit comes on top. `half_year` runs January to June
175
+ // and July to December.
180
176
  export const CreditPeriod = z.enum([...CapPeriod.options, 'half_year']);
181
177
  export const CreditSchema = z.strictObject({
182
178
  id: KebabId,
@@ -34,6 +34,9 @@ export const MerchantSchema = z.strictObject({
34
34
  // Costco Travel), or a booking made through one (booking: "Hotel through
35
35
  // Amex Travel").
36
36
  kind: z.enum(['portal', 'booking']).optional(),
37
+ // "on Walmart+ membership" rather than "at": a membership or subscription,
38
+ // not a store.
39
+ preposition: z.literal('on').optional(),
37
40
  note: z.string().optional(),
38
41
  });
39
42
  export const MerchantsFileSchema = z.array(MerchantSchema);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -54,6 +54,7 @@
54
54
  "monitor:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy --config monitor/wrangler.jsonc",
55
55
  "site:build": "vite build site && node scripts/build-pages.ts",
56
56
  "site:dev": "vite site",
57
+ "site:preview": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy --config wrangler.preview.jsonc",
57
58
  "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy && node scripts/indexnow.ts",
58
59
  "test": "vitest run",
59
60
  "test:watch": "vitest",