earnrate 0.2.3 → 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 (71) hide show
  1. package/README.md +15 -14
  2. package/data/CHANGELOG.md +74 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/amex-gold.yaml +4 -0
  6. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  9. package/data/cards/chase-freedom-flex.yaml +1 -1
  10. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  11. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  12. package/data/cards/chase-southwest-plus.yaml +4 -0
  13. package/data/cards/chase-southwest-premier.yaml +4 -0
  14. package/data/cards/chase-southwest-priority.yaml +4 -0
  15. package/data/cards/chase-united-club.yaml +1 -1
  16. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  17. package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
  18. package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
  19. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  20. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  21. package/data/categories.yaml +12 -0
  22. package/data/currencies.yaml +2 -0
  23. package/data/memberships.yaml +33 -0
  24. package/data/merchants/overrides.yaml +33 -0
  25. package/data/merchants/seed.yaml +0 -6
  26. package/dist/cli/cheatsheet.js +3 -3
  27. package/dist/cli/explore.js +12 -11
  28. package/dist/cli/import.js +13 -12
  29. package/dist/cli/offers.js +2 -1
  30. package/dist/cli/run.js +18 -4
  31. package/dist/cli/wallet.js +42 -0
  32. package/dist/data/file-store.js +5 -2
  33. package/dist/data/integrity.js +26 -0
  34. package/dist/data/load.js +10 -2
  35. package/dist/data/store.js +8 -1
  36. package/dist/data/wallet-edit.js +83 -2
  37. package/dist/engine/cheat-sheet.js +53 -14
  38. package/dist/engine/compare.js +8 -0
  39. package/dist/engine/explore.js +18 -20
  40. package/dist/engine/memberships.js +71 -0
  41. package/dist/engine/offers.js +37 -5
  42. package/dist/engine/profiles.js +8 -0
  43. package/dist/engine/recommend.js +78 -44
  44. package/dist/engine/setup.js +22 -5
  45. package/dist/engine/simulate.js +36 -14
  46. package/dist/engine/text.js +67 -2
  47. package/dist/engine/valuation.js +1 -1
  48. package/dist/engine/wallet-change.js +42 -4
  49. package/dist/engine/wallet-summary.js +83 -37
  50. package/dist/format/card-text.js +19 -29
  51. package/dist/format/cards.js +8 -9
  52. package/dist/format/explore.js +22 -14
  53. package/dist/format/offers.js +12 -21
  54. package/dist/format/recommendation.js +16 -10
  55. package/dist/format/wallet-yaml.js +18 -3
  56. package/dist/format/wallet.js +16 -20
  57. package/dist/mcp/context.js +31 -4
  58. package/dist/mcp/inputs.js +17 -8
  59. package/dist/mcp/schemas.js +21 -0
  60. package/dist/mcp/server.js +1 -0
  61. package/dist/mcp/tools/account.js +28 -10
  62. package/dist/mcp/tools/answers.js +51 -18
  63. package/dist/mcp/tools/wallet.js +106 -11
  64. package/dist/schema/card.js +7 -0
  65. package/dist/schema/category.js +4 -0
  66. package/dist/schema/currency.js +2 -0
  67. package/dist/schema/membership.js +15 -0
  68. package/dist/schema/merchant.js +4 -0
  69. package/dist/schema/wallet.js +4 -0
  70. package/package.json +1 -1
  71. package/wallet.example.yaml +1 -0
@@ -4,49 +4,43 @@ import { daysBetween } from "./periods.js";
4
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  import { activationRules } from "./setup.js";
7
- import { categoryPhrase, earnText } from "./text.js";
8
- import { cardValuation } from "./valuation.js";
9
- const NUMBERS = {
10
- one: '1',
11
- two: '2',
12
- three: '3',
13
- four: '4',
14
- five: '5',
15
- six: '6',
16
- };
17
- // "five-percent" reads "5%"; other ids read as words.
18
- export function choiceName(id) {
19
- return id
20
- .replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
21
- .replace(/[-_.]/g, ' ');
22
- }
23
- const listed = (parts) => parts.length <= 2
24
- ? parts.join(' and ')
25
- : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
7
+ import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
8
+ import { cardValuation, currencyValuation } from "./valuation.js";
9
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
10
+ // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
11
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
26
12
  function merchantName(catalog, id) {
27
13
  const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
28
- return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
14
+ const merchant = all.find((m) => m.id === id);
15
+ const name = merchant?.name ?? words(id);
16
+ return merchant?.kind === 'booking'
17
+ ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
18
+ : name;
29
19
  }
30
- function optionName(catalog, card, choiceId, option) {
20
+ export function optionName(catalog, card, choiceId, option) {
31
21
  const o = card.choices.find((c) => c.id === choiceId)?.options[option];
32
22
  if (!o)
33
- return option.replace(/[-_.]/g, ' ');
23
+ return words(option);
34
24
  const parts = [
35
25
  ...o.categories.map((c) => categoryPhrase(catalog, c)),
36
26
  ...o.merchants.map((m) => merchantName(catalog, m)),
37
27
  ];
38
28
  if (o.online)
39
29
  parts.push('online shopping');
40
- return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
30
+ return parts.length > 0 ? listed(parts) : words(option);
41
31
  }
42
- function ruleWhat(catalog, rule) {
32
+ // What a rule covers, as a list of phrases.
33
+ function ruleParts(catalog, rule) {
43
34
  if (rule.match.choice !== undefined)
44
- return `your ${choiceName(rule.match.choice)} picks`;
35
+ return [`your ${choiceName(rule.match.choice)} picks`];
45
36
  const parts = [
46
37
  ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
47
38
  ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
48
39
  ];
49
- return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
40
+ return parts.length > 0 ? parts : [words(rule.id)];
41
+ }
42
+ function ruleWhat(catalog, rule) {
43
+ return listed(ruleParts(catalog, rule));
50
44
  }
51
45
  function periodEnd(period) {
52
46
  const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
@@ -63,14 +57,45 @@ function periodEnd(period) {
63
57
  }
64
58
  const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
65
59
  (rule.valid_to === undefined || rule.valid_to >= today);
66
- function headlineRates(catalog, card, currency, today) {
67
- const best = card.rules
68
- .filter((r) => applies(r, today) && r.rate > card.base_rate)
69
- .sort((a, b) => b.rate - a.rate)
70
- .slice(0, 4)
71
- .map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
60
+ const matchKeys = (rule) => rule.match.choice !== undefined
61
+ ? []
62
+ : [
63
+ ...(rule.match.categories ?? []).map((c) => `category:${c}`),
64
+ ...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
65
+ ];
66
+ // Every bonus rate the cardholder gets now, best first, so none reads as the
67
+ // base rate. Rates behind a membership the wallet lacks are left out, and so
68
+ // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
69
+ // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
70
+ // a line.
71
+ function headlineRates(catalog, wallet, card, held, currency, today) {
72
+ const shown = card.rules
73
+ .filter((r) => applies(r, today) &&
74
+ r.rate > card.base_rate &&
75
+ (r.requires?.membership === undefined ||
76
+ wallet.memberships.includes(r.requires.membership)))
77
+ .sort((a, b) => b.rate - a.rate);
78
+ const lines = new Map();
79
+ for (const r of shown) {
80
+ const keys = matchKeys(r);
81
+ const higher = shown
82
+ .filter((h) => h.rate > r.rate &&
83
+ h.cap === undefined &&
84
+ (!h.requires?.activation || isActivated(card, held, h.id)))
85
+ .flatMap(matchKeys);
86
+ if (keys.length > 0 && keys.every((k) => higher.includes(k)))
87
+ continue;
88
+ const pending = r.requires?.activation && !isActivated(card, held, r.id);
89
+ const head = `${earnText(catalog, r.rate, currency)} on`;
90
+ const key = pending ? `${head}|activate` : head;
91
+ const whats = lines.get(key) ?? [];
92
+ for (const part of ruleParts(catalog, r))
93
+ if (!whats.includes(part))
94
+ whats.push(part);
95
+ lines.set(key, whats);
96
+ }
72
97
  return [
73
- ...best,
98
+ ...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
74
99
  `${earnText(catalog, card.base_rate, currency)} on everything else`,
75
100
  ];
76
101
  }
@@ -113,7 +138,7 @@ function capSummaries(catalog, card, held, today) {
113
138
  const label = shared?.rotating
114
139
  ? 'the rotating bonus categories'
115
140
  : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
116
- key.replace(/[-_.]/g, ' ');
141
+ words(key);
117
142
  return {
118
143
  key,
119
144
  label,
@@ -126,6 +151,13 @@ function capSummaries(catalog, card, held, today) {
126
151
  };
127
152
  });
128
153
  }
154
+ // "into a SoFi account", for a wallet that values points by a redemption.
155
+ function redemptionOf(catalog, currency, v) {
156
+ if (typeof v !== 'object' || v.redemption === undefined)
157
+ return null;
158
+ const names = catalog.currencies.find((c) => c.id === currency)?.redemption_names;
159
+ return names?.[v.redemption] ?? `as ${words(v.redemption)}`;
160
+ }
129
161
  export function walletSummary(catalog, wallet, today) {
130
162
  const cards = [];
131
163
  for (const held of wallet.cards) {
@@ -142,6 +174,9 @@ export function walletSummary(catalog, wallet, today) {
142
174
  onlyAt: card.only_at ?? null,
143
175
  bonus: bonusStatus(catalog, wallet, held, today),
144
176
  currency: valuation.currency,
177
+ currencyName: valuation.currency === 'cash'
178
+ ? 'cash back'
179
+ : currencyLabel(catalog, valuation.currency),
145
180
  centsPerUnit: valuation.centsPerUnit,
146
181
  lastVerified: card.last_verified,
147
182
  verifiedBy: card.verified_by,
@@ -152,7 +187,10 @@ export function walletSummary(catalog, wallet, today) {
152
187
  ? {
153
188
  period: held.chosen.period,
154
189
  current: pickedCategories(card, held, today).staleChoice === null,
155
- until: periodEnd(held.chosen.period),
190
+ // Picks that carry over have no end date.
191
+ until: card.choices.every((c) => c.period === 'until_changed')
192
+ ? null
193
+ : periodEnd(held.chosen.period),
156
194
  picks: held.chosen.picks,
157
195
  pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
158
196
  choiceName(choice),
@@ -160,11 +198,19 @@ export function walletSummary(catalog, wallet, today) {
160
198
  ])),
161
199
  }
162
200
  : null,
163
- rates: headlineRates(catalog, card, valuation.currency, today),
201
+ rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
164
202
  activated: held.activated,
165
203
  toActivate: toActivate(catalog, card, held, today),
166
204
  anniversary: held.anniversary ?? null,
167
205
  });
168
206
  }
169
- return { memberships: wallet.memberships, cards };
207
+ const pointValues = Object.keys(wallet.valuations)
208
+ .filter((id) => catalog.currencies.some((c) => c.id === id))
209
+ .map((id) => ({
210
+ currency: id,
211
+ name: currencyLabel(catalog, id),
212
+ centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
213
+ redemption: redemptionOf(catalog, id, wallet.valuations[id]),
214
+ }));
215
+ return { memberships: wallet.memberships, pointValues, started: null, cards };
170
216
  }
@@ -13,31 +13,12 @@ const ISSUERS = {
13
13
  usbank: 'U.S. Bank',
14
14
  'wells-fargo': 'Wells Fargo',
15
15
  };
16
- export const MEMBERSHIPS = {
17
- 'amazon-prime': 'Amazon Prime',
18
- 'bank-of-america-account': 'Bank of America checking or savings',
19
- 'bofa-rewards-member': 'BofA Rewards Member',
20
- 'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
21
- 'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
22
- 'bofa-rewards-premier': 'BofA Rewards Premier',
23
- };
24
- export const PICK_PERIODS = {
25
- month: 'each month',
26
- quarter: 'each quarter',
27
- calendar_year: 'each year',
28
- until_changed: 'until you change it',
29
- };
30
- export const CAP_PERIODS = {
31
- month: 'a month',
32
- quarter: 'a quarter',
33
- calendar_year: 'a year',
34
- cardmember_year: 'a card year',
35
- };
36
- export const words = (id) => id.replace(/[-_.]/g, ' ');
16
+ export { CAP_PERIODS, PICK_PERIODS, words } from "../engine/text.js";
17
+ import { membershipName, membershipText } from "../engine/memberships.js";
18
+ import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
37
19
  // Whole dollars, for fees and caps.
38
- export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
20
+ export const usd = wholeDollars;
39
21
  export const issuerName = (id) => ISSUERS[id] ?? id;
40
- export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
41
22
  export function cardText(catalog) {
42
23
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
43
24
  function shortName(id) {
@@ -50,15 +31,17 @@ export function cardText(catalog) {
50
31
  }
51
32
  function feeLabel(id) {
52
33
  const card = cards.get(id);
34
+ const m = card?.requires_membership;
35
+ const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
53
36
  const fee = card?.annual_fee;
54
37
  if (fee === undefined)
55
38
  return 'Annual fee not on file';
56
39
  if (fee === 0)
57
- return 'No annual fee';
40
+ return `No annual fee${needs}`;
58
41
  const first = card?.first_year_fee;
59
42
  if (first !== undefined && first !== fee)
60
- return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
61
- return `${usd(fee)} a year`;
43
+ return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
44
+ return `${usd(fee)} a year${needs}`;
62
45
  }
63
46
  // `until` is the last day a time-limited rate applies, already formatted.
64
47
  function requirementLines(cardId, r, until = null) {
@@ -68,12 +51,19 @@ export function cardText(catalog) {
68
51
  if (r.activation !== null)
69
52
  lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
70
53
  if (r.membership !== null)
71
- lines.push(`With ${membershipName(r.membership)}`);
54
+ lines.push(`With ${membershipName(catalog, r.membership)}`);
72
55
  if (until !== null)
73
56
  lines.push(`This rate runs through ${until}`);
74
57
  if (r.cap)
75
- lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period] ?? ''}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
58
+ lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
76
59
  return lines;
77
60
  }
78
- return { cards, shortName, feeLabel, requirementLines };
61
+ const memberName = (id) => membershipName(catalog, id);
62
+ return {
63
+ cards,
64
+ shortName,
65
+ feeLabel,
66
+ requirementLines,
67
+ membershipName: memberName,
68
+ };
79
69
  }
@@ -1,18 +1,17 @@
1
- const NETWORKS = {
2
- visa: 'Visa',
3
- mastercard: 'Mastercard',
4
- amex: 'Amex',
5
- discover: 'Discover',
6
- store: 'store card',
7
- };
1
+ import { money, NETWORK_NAMES } from "../engine/text.js";
8
2
  export function formatCardInfo(card) {
9
3
  const fee = card.annualFee === null
10
4
  ? ''
11
5
  : card.annualFee === 0
12
6
  ? ', no annual fee'
13
- : `, $${String(card.annualFee)} a year`;
7
+ : `, ${money(card.annualFee)} a year`;
14
8
  const needs = [
15
9
  ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
10
+ ...(card.requiresMembership
11
+ ? [
12
+ `needs ${card.requiresMembership.name} (${money(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
13
+ ]
14
+ : []),
16
15
  ...(card.memberships.length > 0
17
16
  ? [`rates that need a membership: ${card.memberships.join(', ')}`]
18
17
  : []),
@@ -23,7 +22,7 @@ export function formatCardInfo(card) {
23
22
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
23
  ];
25
24
  return [
26
- `${card.id}: ${card.name} (${NETWORKS[card.network]}${fee})${card.held ? ' [in wallet]' : ''}`,
25
+ `${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
27
26
  ...needs.map((n) => ` ${n}`),
28
27
  ].join('\n');
29
28
  }
@@ -1,7 +1,10 @@
1
- import { feeText, money, pct } from "../engine/text.js";
2
- const wholeUsd = (n) => money(Math.round(n));
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}`)
5
+ .join('');
3
6
  const TOP_BEST = 10;
4
- export function formatExploration(r, all) {
7
+ export function formatExploration(r, catalog, all) {
5
8
  const m = r.merchant;
6
9
  const title = m.source === 'category'
7
10
  ? m.resolvedName.toLowerCase()
@@ -20,7 +23,7 @@ export function formatExploration(r, all) {
20
23
  lines.push(` ${c}`);
21
24
  const upgrade = e.withMembership[0];
22
25
  if (upgrade)
23
- lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
26
+ lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
24
27
  for (const w of e.warnings)
25
28
  lines.push(` ! ${w}`);
26
29
  });
@@ -32,8 +35,8 @@ export function formatExploration(r, all) {
32
35
  return `${lines.join('\n')}\n`;
33
36
  }
34
37
  function picksText(picks) {
35
- const parts = Object.entries(picks).map(([choice, options]) => `${choice}: ${options.join(', ')}`);
36
- 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('; ')})`;
37
40
  }
38
41
  function cardNamer(catalog) {
39
42
  return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
@@ -56,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
56
59
  return 'No cards match.\n';
57
60
  const top = all ? 20 : TOP_SIMULATED;
58
61
  const lines = [
59
- `A year of this spending: ${wholeUsd(first.spend)}.`,
62
+ `A year of this spending: ${wholeDollars(first.spend)}.`,
60
63
  '',
61
64
  'Best single cards (rewards minus annual fee, per year):',
62
65
  ];
@@ -64,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
64
67
  const card = s.cards[0];
65
68
  if (!card)
66
69
  return;
67
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeUsd(s.net)} (${wholeUsd(s.rewards)} rewards, ${wholeUsd(s.annualFees)} fee)${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)}`);
68
71
  });
69
72
  const pairs = r.pairs.slice(0, top);
70
73
  if (pairs.length > 0) {
71
74
  lines.push('', 'Best two-card pairs:');
72
75
  pairs.forEach((s, i) => {
73
- 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)}`);
74
77
  if (s.alternatives.length > 0)
75
78
  lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
76
79
  for (const split of splitText(s, name))
@@ -92,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
92
95
  export function formatAdditions(r, catalog, all) {
93
96
  const name = cardNamer(catalog);
94
97
  const lines = [
95
- `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` : ''}.`,
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` : ''}.`,
96
99
  ];
97
100
  for (const split of splitText(r.current, name))
98
101
  lines.push(` ${split}`);
@@ -101,14 +104,19 @@ export function formatAdditions(r, catalog, all) {
101
104
  lines.push('', 'No card would add more than its annual fee costs.');
102
105
  }
103
106
  else {
104
- lines.push('', 'Cards that would add the most (after their annual fee):');
107
+ lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
105
108
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
106
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeUsd(a.gain)} a year (+${wholeUsd(a.extraRewards)} rewards, ${wholeUsd(a.annualFee)} fee)${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)}`);
107
110
  });
108
111
  }
109
- const notes = new Set(r.current.notes);
112
+ const notes = new Set([
113
+ ...r.current.notes,
114
+ ...gains
115
+ .slice(0, all ? 20 : TOP_SIMULATED)
116
+ .flatMap((a) => a.simulation.memberships.map((m) => membershipNote(catalog, m))),
117
+ ]);
110
118
  if (notes.size > 0)
111
119
  lines.push('', ...notes);
112
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
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.');
113
121
  return `${lines.join('\n')}\n`;
114
122
  }
@@ -1,17 +1,6 @@
1
- import { feeText, money } 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
- const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
- month: 'short',
11
- day: 'numeric',
12
- year: 'numeric',
13
- timeZone: 'UTC',
14
- });
15
4
  export function formatOffers(c, currencyName, all) {
16
5
  if (c.offers.length === 0) {
17
6
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -21,17 +10,19 @@ export function formatOffers(c, currencyName, all) {
21
10
  const lines = [];
22
11
  shown.forEach((o, i) => {
23
12
  const weeks = (o.months * 52) / 12;
24
- const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
25
- const spend = o.spend > 0
26
- ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
27
- : 'with no spending required';
28
- 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.held ? ' In your wallet.' : ''}`);
29
- lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(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.`);
16
+ if (o.ineligible)
17
+ lines.push(` ${o.ineligible}`);
30
18
  if (o.note)
31
19
  lines.push(` ${o.note}`);
32
20
  });
33
- if (c.offers.length > shown.length)
34
- 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)`);
35
26
  if (c.withoutOffer.length > 0)
36
27
  lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
37
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;
@@ -24,13 +24,15 @@ function header(rec, purchase) {
24
24
  parts.push(m.channel === 'online' ? 'online' : 'in-store');
25
25
  return parts.join(' · ');
26
26
  }
27
- function cardLines(cards, style) {
27
+ function cardLines(cards, style, bonusPick) {
28
+ const rankWidth = Math.max(...cards.map((c) => c.rank));
28
29
  const nameWidth = Math.max(...cards.map((c) => label(c).length));
29
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
30
31
  const lines = [];
31
- cards.forEach((c, i) => {
32
- const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
33
- lines.push(` ${String(i + 1)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}`);
32
+ cards.forEach((c) => {
33
+ const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
34
+ const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
+ lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
34
36
  for (const r of c.reasoning)
35
37
  lines.push(style('dim', ` ${r}`));
36
38
  for (const w of c.warnings)
@@ -48,9 +50,9 @@ function excludedLines(excluded) {
48
50
  export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
49
51
  const lines = [header(rec, purchase), ''];
50
52
  const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
51
- const noCard = `None of your cards work at ${rec.merchant.resolvedName}.`;
53
+ const noCard = noCardText(rec.merchant.resolvedName);
52
54
  if (rec.decision && top) {
53
- const earned = top.earned === null ? '' : `, about ${cents(top.earned)}`;
55
+ const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
54
56
  const confidence = rec.decision.confidence;
55
57
  const why = rec.bonus ? ' for its welcome bonus' : '';
56
58
  lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${confidence[0]?.toUpperCase() ?? ''}${confidence.slice(1)} confidence.`);
@@ -60,9 +62,13 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
60
62
  else {
61
63
  lines.push(style('bold', noCard));
62
64
  }
63
- const shown = all ? rec.ranked : rec.ranked.slice(0, TOP);
65
+ const bonusPick = rec.bonus?.cardId ?? null;
66
+ const ranked = rec.ranked.map((c, i) => ({ ...c, rank: i + 1 }));
67
+ const shown = all
68
+ ? ranked
69
+ : ranked.filter((c, i) => i < TOP || c.cardId === bonusPick);
64
70
  if (shown.length > 0)
65
- lines.push('', ...cardLines(shown, style));
71
+ lines.push('', ...cardLines(shown, style, bonusPick));
66
72
  const hidden = rec.ranked.length - shown.length;
67
73
  if (hidden > 0) {
68
74
  lines.push(style('dim', ` + ${String(hidden)} more ${hidden === 1 ? 'card' : 'cards'} (--all lists them)`));
@@ -1,10 +1,11 @@
1
- import { parse, stringify } from 'yaml';
1
+ import { Document, parse, visit } from 'yaml';
2
2
  import { walletProblems } from "../data/integrity.js";
3
- import { WalletSchema } from "../schema/wallet.js";
3
+ import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
4
  // A wallet as the YAML file the CLI reads, for moving it between the
5
5
  // website, the remote server and the command line. Node-free.
6
6
  function compact(wallet) {
7
7
  return {
8
+ version: wallet.version ?? WALLET_VERSION,
8
9
  ...(Object.keys(wallet.valuations).length > 0
9
10
  ? { valuations: wallet.valuations }
10
11
  : {}),
@@ -26,8 +27,22 @@ function compact(wallet) {
26
27
  })),
27
28
  };
28
29
  }
30
+ // Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
31
+ // date, not a string.
32
+ function quotedYaml(value) {
33
+ const doc = new Document(value);
34
+ visit(doc, {
35
+ Scalar(key, node) {
36
+ if (key === 'value' &&
37
+ typeof node.value === 'string' &&
38
+ /^(\d{4}(-|$)|\d{2}-\d{2}$)/.test(node.value))
39
+ node.type = 'QUOTE_DOUBLE';
40
+ },
41
+ });
42
+ return doc.toString();
43
+ }
29
44
  export function walletYaml(wallet) {
30
- return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${stringify(compact(wallet))}`;
45
+ return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${quotedYaml(compact(wallet))}`;
31
46
  }
32
47
  export function parseWalletYaml(text, catalog) {
33
48
  let raw;
@@ -1,24 +1,11 @@
1
- import { money } from "../engine/text.js";
2
- const NETWORKS = {
3
- visa: 'Visa',
4
- mastercard: 'Mastercard',
5
- amex: 'American Express',
6
- discover: 'Discover',
7
- store: 'store card',
8
- };
9
- const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
- month: 'short',
11
- day: 'numeric',
12
- year: 'numeric',
13
- timeZone: 'UTC',
14
- });
1
+ import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
15
2
  export function formatWalletSummary(summary) {
16
3
  const cards = summary.cards.map((c) => {
17
4
  const label = c.checkoutLabel
18
5
  ? `, shown at checkout as "${c.checkoutLabel}"`
19
6
  : '';
20
7
  const lines = [
21
- `- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
8
+ `- ${c.cardName} (${c.cardId})${label}: ${NETWORK_NAMES[c.network] ?? c.network}, ${c.currencyName} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
22
9
  ` Earns: ${c.rates.join('; ')}.`,
23
10
  ];
24
11
  if (c.onlyAt)
@@ -27,10 +14,14 @@ export function formatWalletSummary(summary) {
27
14
  const picks = Object.entries(c.chosen.pickNames)
28
15
  .map(([choice, names]) => `${choice}: ${names.join(', ')}`)
29
16
  .join('; ');
30
- const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
31
- lines.push(c.chosen.current
32
- ? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
33
- : ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
17
+ const until = c.chosen.until
18
+ ? `, through ${shortDate(c.chosen.until)}`
19
+ : '';
20
+ lines.push(!c.chosen.current
21
+ ? ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`
22
+ : c.chosen.until === null
23
+ ? ` Picks: ${picks}. They stay until the user changes them.`
24
+ : ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`);
34
25
  }
35
26
  if (c.toActivate.length > 0)
36
27
  lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
@@ -39,6 +30,8 @@ export function formatWalletSummary(summary) {
39
30
  for (const cap of c.caps)
40
31
  if (cap.remaining !== null)
41
32
  lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
33
+ if (c.anniversary !== null)
34
+ lines.push(` Account anniversary: ${c.anniversary}.`);
42
35
  const b = c.bonus;
43
36
  if (b?.state === 'open' && b.spend !== null)
44
37
  lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
@@ -49,7 +42,10 @@ export function formatWalletSummary(summary) {
49
42
  const memberships = summary.memberships.length > 0
50
43
  ? `Memberships: ${summary.memberships.join(', ')}.`
51
44
  : 'Memberships: none saved.';
52
- 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');
53
49
  }
54
50
  export function formatBooking(amount, cardId, cap, bonus) {
55
51
  const messages = [];