earnrate 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +13 -12
  2. package/data/CHANGELOG.md +35 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/chase-freedom-flex.yaml +1 -1
  6. package/data/cards/chase-southwest-plus.yaml +4 -0
  7. package/data/cards/chase-southwest-premier.yaml +4 -0
  8. package/data/cards/chase-southwest-priority.yaml +4 -0
  9. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  10. package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
  11. package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
  12. package/data/merchants/overrides.yaml +12 -1
  13. package/data/merchants/seed.yaml +0 -6
  14. package/dist/cli/cheatsheet.js +1 -1
  15. package/dist/cli/explore.js +1 -1
  16. package/dist/cli/import.js +4 -3
  17. package/dist/cli/run.js +1 -1
  18. package/dist/data/file-store.js +3 -1
  19. package/dist/data/integrity.js +10 -0
  20. package/dist/data/store.js +9 -1
  21. package/dist/data/wallet-edit.js +8 -2
  22. package/dist/engine/cheat-sheet.js +44 -2
  23. package/dist/engine/explore.js +14 -5
  24. package/dist/engine/memberships.js +45 -0
  25. package/dist/engine/offers.js +22 -2
  26. package/dist/engine/recommend.js +91 -31
  27. package/dist/engine/setup.js +23 -4
  28. package/dist/engine/simulate.js +32 -10
  29. package/dist/engine/text.js +51 -0
  30. package/dist/engine/wallet-change.js +27 -7
  31. package/dist/engine/wallet-summary.js +133 -3
  32. package/dist/format/card-text.js +7 -13
  33. package/dist/format/cards.js +7 -8
  34. package/dist/format/explore.js +17 -8
  35. package/dist/format/offers.js +5 -9
  36. package/dist/format/recommendation.js +11 -5
  37. package/dist/format/wallet-yaml.js +18 -3
  38. package/dist/format/wallet.js +21 -18
  39. package/dist/mcp/context.js +7 -3
  40. package/dist/mcp/inputs.js +2 -2
  41. package/dist/mcp/schemas.js +18 -0
  42. package/dist/mcp/server.js +3 -3
  43. package/dist/mcp/tools/account.js +55 -7
  44. package/dist/mcp/tools/answers.js +24 -7
  45. package/dist/mcp/tools/wallet.js +49 -7
  46. package/dist/schema/card.js +14 -0
  47. package/dist/schema/wallet.js +4 -0
  48. package/package.json +1 -1
  49. package/wallet.example.yaml +1 -0
@@ -2,6 +2,15 @@ import { singleMcc } from "./evaluate.js";
2
2
  export const num = (n) => String(Number(n.toFixed(2)));
3
3
  export const pct = (n) => `${num(n)}%`;
4
4
  export const roundCents = (n) => Math.round(n * 100) / 100;
5
+ // An id in words: "gas-ev-charging" reads "gas ev charging".
6
+ export const words = (id) => id.replace(/[-_.]/g, ' ');
7
+ // "Dec 31, 2026" from YYYY-MM-DD, the same in every time zone.
8
+ export const shortDate = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
9
+ month: 'short',
10
+ day: 'numeric',
11
+ year: 'numeric',
12
+ timeZone: 'UTC',
13
+ });
5
14
  // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
6
15
  export const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
7
16
  export function feeText(fees) {
@@ -51,3 +60,45 @@ export function pointValuesText(catalog, shown) {
51
60
  const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
52
61
  return `Values points at: ${list.join(', ')}.`;
53
62
  }
63
+ export const NETWORK_NAMES = {
64
+ visa: 'Visa',
65
+ mastercard: 'Mastercard',
66
+ amex: 'American Express',
67
+ discover: 'Discover',
68
+ store: 'store card',
69
+ };
70
+ // A name with its own "and" ("drugstores and pharmacies") would run into the
71
+ // list's: those lists use commas only, with those names last.
72
+ export const listed = (parts) => {
73
+ const anded = parts.filter((p) => p.includes(' and '));
74
+ if (anded.length > 0)
75
+ return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
76
+ return parts.length <= 2
77
+ ? parts.join(' and ')
78
+ : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
79
+ };
80
+ const NUMBERS = {
81
+ one: '1',
82
+ two: '2',
83
+ three: '3',
84
+ four: '4',
85
+ five: '5',
86
+ six: '6',
87
+ };
88
+ // "five-percent" reads "5%"; other ids read as words.
89
+ export function choiceName(id) {
90
+ return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
91
+ }
92
+ // Display names for membership ids in the card data.
93
+ export const MEMBERSHIPS = {
94
+ 'amazon-prime': 'Amazon Prime',
95
+ 'bank-of-america-account': 'Bank of America checking or savings',
96
+ 'bank-of-america-small-business-account': 'Bank of America small business checking or savings',
97
+ 'bofa-rewards-member': 'BofA Rewards Member',
98
+ 'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
99
+ 'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
100
+ 'bofa-rewards-premier': 'BofA Rewards Premier',
101
+ costco: 'Costco membership',
102
+ 'nordy-club-ambassador': 'Nordy Club Ambassador',
103
+ };
104
+ export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
@@ -1,3 +1,5 @@
1
+ import { WALLET_VERSION, } from "../schema/wallet.js";
2
+ import { addMonths } from "./bonus.js";
1
3
  import { capByKey } from "./evaluate.js";
2
4
  import { choicePeriod, periodKey } from "./periods.js";
3
5
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
@@ -132,11 +134,25 @@ export function cardFields(card, held, input, today) {
132
134
  problems.push(`"${b.by}" isn't a deadline after the opening date.`);
133
135
  if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
134
136
  problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
137
+ // The terms are saved with the bonus, so a later change to the published
138
+ // offer, or a wallet exported and imported again, keeps the user's own.
139
+ const offer = card.welcome_offer;
140
+ const before = held?.bonus?.opened === b.opened ? held.bonus : undefined;
141
+ const spend = b.spend ?? before?.spend ?? offer?.spend;
142
+ const by = b.by ??
143
+ before?.by ??
144
+ (offer && isDate(b.opened)
145
+ ? addMonths(b.opened, offer.months)
146
+ : undefined);
147
+ const points = before?.points ?? (before?.dollars ? undefined : offer?.points);
148
+ const dollars = before?.dollars ?? (points !== undefined ? undefined : offer?.dollars);
135
149
  fields.bonus = {
136
150
  opened: b.opened,
137
151
  spent: b.spent ?? held?.bonus?.spent ?? 0,
138
- ...(b.spend === undefined ? {} : { spend: b.spend }),
139
- ...(b.by === undefined ? {} : { by: b.by }),
152
+ ...(spend === undefined ? {} : { spend }),
153
+ ...(by === undefined ? {} : { by }),
154
+ ...(points === undefined ? {} : { points }),
155
+ ...(dollars === undefined ? {} : { dollars }),
140
156
  };
141
157
  }
142
158
  return problems.length > 0 ? problems.join('\n') : fields;
@@ -173,7 +189,12 @@ export function addToWallet(catalog, wallet, input, today) {
173
189
  const held = withFields(newHolding(card.id), fields);
174
190
  const next = wallet
175
191
  ? { ...wallet, cards: [...wallet.cards, held] }
176
- : { valuations: {}, memberships: [], cards: [held] };
192
+ : {
193
+ version: WALLET_VERSION,
194
+ valuations: {},
195
+ memberships: [],
196
+ cards: [held],
197
+ };
177
198
  return {
178
199
  ok: true,
179
200
  wallet: next,
@@ -208,9 +229,8 @@ export function removeFromWallet(wallet, id) {
208
229
  if (!wallet.cards.some((c) => c.id === id)) {
209
230
  return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
210
231
  }
211
- if (wallet.cards.length === 1) {
212
- return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
213
- }
232
+ // Removing the last card leaves an empty wallet, which each store keeps
233
+ // in its own way (or refuses, for a wallet file).
214
234
  return {
215
235
  ok: true,
216
236
  wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
@@ -222,7 +242,7 @@ export function withMemberships(catalog, wallet, memberships) {
222
242
  const known = knownMemberships(catalog);
223
243
  const unknown = memberships.filter((m) => !known.includes(m));
224
244
  if (unknown.length > 0) {
225
- return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
245
+ return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
226
246
  }
227
247
  return {
228
248
  ok: true,
@@ -1,10 +1,120 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
- import { capUsed, pickedCategories } from "./evaluate.js";
2
+ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
3
  import { daysBetween } from "./periods.js";
4
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { activationRules } from "./setup.js";
7
+ import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
6
8
  import { cardValuation } from "./valuation.js";
7
- function capSummaries(card, held, today) {
9
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
10
+ // lower case and plural; brand names keep theirs.
11
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
12
+ function merchantName(catalog, id) {
13
+ const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
14
+ const name = all.find((m) => m.id === id)?.name ?? words(id);
15
+ return name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`);
16
+ }
17
+ function optionName(catalog, card, choiceId, option) {
18
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
19
+ if (!o)
20
+ return words(option);
21
+ const parts = [
22
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
23
+ ...o.merchants.map((m) => merchantName(catalog, m)),
24
+ ];
25
+ if (o.online)
26
+ parts.push('online shopping');
27
+ return parts.length > 0 ? listed(parts) : words(option);
28
+ }
29
+ // What a rule covers, as a list of phrases.
30
+ function ruleParts(catalog, rule) {
31
+ if (rule.match.choice !== undefined)
32
+ return [`your ${choiceName(rule.match.choice)} picks`];
33
+ const parts = [
34
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
35
+ ...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
36
+ ];
37
+ return parts.length > 0 ? parts : [words(rule.id)];
38
+ }
39
+ function ruleWhat(catalog, rule) {
40
+ return listed(ruleParts(catalog, rule));
41
+ }
42
+ function periodEnd(period) {
43
+ const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
44
+ if (quarter) {
45
+ const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
46
+ return `${quarter[1] ?? ''}-${end ?? ''}`;
47
+ }
48
+ const month = /^(\d{4})-(\d{2})$/.exec(period);
49
+ if (month) {
50
+ const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
51
+ return last.toISOString().slice(0, 10);
52
+ }
53
+ return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
54
+ }
55
+ const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
56
+ (rule.valid_to === undefined || rule.valid_to >= today);
57
+ const matchKeys = (rule) => rule.match.choice !== undefined
58
+ ? []
59
+ : [
60
+ ...(rule.match.categories ?? []).map((c) => `category:${c}`),
61
+ ...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
62
+ ];
63
+ // Every bonus rate the cardholder gets now, best first, so none reads as the
64
+ // base rate. Rates behind a membership the wallet lacks are left out, and so
65
+ // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
66
+ // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
67
+ // a line.
68
+ function headlineRates(catalog, wallet, card, held, currency, today) {
69
+ const shown = card.rules
70
+ .filter((r) => applies(r, today) &&
71
+ r.rate > card.base_rate &&
72
+ (r.requires?.membership === undefined ||
73
+ wallet.memberships.includes(r.requires.membership)))
74
+ .sort((a, b) => b.rate - a.rate);
75
+ const lines = new Map();
76
+ for (const r of shown) {
77
+ const keys = matchKeys(r);
78
+ const higher = shown
79
+ .filter((h) => h.rate > r.rate &&
80
+ h.cap === undefined &&
81
+ (!h.requires?.activation || isActivated(card, held, h.id)))
82
+ .flatMap(matchKeys);
83
+ if (keys.length > 0 && keys.every((k) => higher.includes(k)))
84
+ continue;
85
+ const pending = r.requires?.activation && !isActivated(card, held, r.id);
86
+ const head = `${earnText(catalog, r.rate, currency)} on`;
87
+ const key = pending ? `${head}|activate` : head;
88
+ const whats = lines.get(key) ?? [];
89
+ for (const part of ruleParts(catalog, r))
90
+ if (!whats.includes(part))
91
+ whats.push(part);
92
+ lines.set(key, whats);
93
+ }
94
+ return [
95
+ ...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
96
+ `${earnText(catalog, card.base_rate, currency)} on everything else`,
97
+ ];
98
+ }
99
+ function toActivate(catalog, card, held, today) {
100
+ const seen = new Set();
101
+ const out = [];
102
+ for (const id of activationRules(card, today)) {
103
+ if (seen.has(id) || isActivated(card, held, id))
104
+ continue;
105
+ const group = activationGroup(card, id);
106
+ for (const g of group)
107
+ seen.add(g);
108
+ const rules = card.rules.filter((r) => group.includes(r.id));
109
+ const first = rules[0];
110
+ const when = first?.valid_from !== undefined && first.valid_to !== undefined
111
+ ? ` (${first.valid_from} to ${first.valid_to})`
112
+ : '';
113
+ out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
114
+ }
115
+ return out;
116
+ }
117
+ function capSummaries(catalog, card, held, today) {
8
118
  const caps = new Map();
9
119
  for (const shared of card.caps)
10
120
  caps.set(shared.id, { cap: shared, rules: [] });
@@ -20,8 +130,15 @@ function capSummaries(card, held, today) {
20
130
  }
21
131
  return [...caps].map(([key, { cap, rules }]) => {
22
132
  const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
133
+ const covered = card.rules.filter((r) => rules.includes(r.id));
134
+ const shared = card.caps.find((c) => c.id === key);
135
+ const label = shared?.rotating
136
+ ? 'the rotating bonus categories'
137
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
138
+ words(key);
23
139
  return {
24
140
  key,
141
+ label,
25
142
  rules,
26
143
  amount: cap.amount,
27
144
  period: cap.period,
@@ -47,20 +164,33 @@ export function walletSummary(catalog, wallet, today) {
47
164
  onlyAt: card.only_at ?? null,
48
165
  bonus: bonusStatus(catalog, wallet, held, today),
49
166
  currency: valuation.currency,
167
+ currencyName: valuation.currency === 'cash'
168
+ ? 'cash back'
169
+ : currencyLabel(catalog, valuation.currency),
50
170
  centsPerUnit: valuation.centsPerUnit,
51
171
  lastVerified: card.last_verified,
52
172
  verifiedBy: card.verified_by,
53
173
  dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
54
174
  rotationGap: rotationGap(card, today),
55
- caps: capSummaries(card, held, today),
175
+ caps: capSummaries(catalog, card, held, today),
56
176
  chosen: held.chosen
57
177
  ? {
58
178
  period: held.chosen.period,
59
179
  current: pickedCategories(card, held, today).staleChoice === null,
180
+ // Picks that carry over have no end date.
181
+ until: card.choices.every((c) => c.period === 'until_changed')
182
+ ? null
183
+ : periodEnd(held.chosen.period),
60
184
  picks: held.chosen.picks,
185
+ pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
186
+ choiceName(choice),
187
+ options.map((o) => optionName(catalog, card, choice, o)),
188
+ ])),
61
189
  }
62
190
  : null,
191
+ rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
63
192
  activated: held.activated,
193
+ toActivate: toActivate(catalog, card, held, today),
64
194
  anniversary: held.anniversary ?? null,
65
195
  });
66
196
  }
@@ -13,14 +13,6 @@ 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
16
  export const PICK_PERIODS = {
25
17
  month: 'each month',
26
18
  quarter: 'each quarter',
@@ -33,11 +25,11 @@ export const CAP_PERIODS = {
33
25
  calendar_year: 'a year',
34
26
  cardmember_year: 'a card year',
35
27
  };
36
- export const words = (id) => id.replace(/[-_.]/g, ' ');
28
+ export { MEMBERSHIPS, membershipName, words } from "../engine/text.js";
29
+ import { membershipName, words } from "../engine/text.js";
37
30
  // Whole dollars, for fees and caps.
38
31
  export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
39
32
  export const issuerName = (id) => ISSUERS[id] ?? id;
40
- export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
41
33
  export function cardText(catalog) {
42
34
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
43
35
  function shortName(id) {
@@ -50,15 +42,17 @@ export function cardText(catalog) {
50
42
  }
51
43
  function feeLabel(id) {
52
44
  const card = cards.get(id);
45
+ const m = card?.requires_membership;
46
+ const needs = m ? `; needs ${m.name} (${usd(m.annual_fee)} a year)` : '';
53
47
  const fee = card?.annual_fee;
54
48
  if (fee === undefined)
55
49
  return 'Annual fee not on file';
56
50
  if (fee === 0)
57
- return 'No annual fee';
51
+ return `No annual fee${needs}`;
58
52
  const first = card?.first_year_fee;
59
53
  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`;
54
+ return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
55
+ return `${usd(fee)} a year${needs}`;
62
56
  }
63
57
  // `until` is the last day a time-limited rate applies, already formatted.
64
58
  function requirementLines(cardId, r, until = null) {
@@ -1,10 +1,4 @@
1
- const NETWORKS = {
2
- visa: 'Visa',
3
- mastercard: 'Mastercard',
4
- amex: 'Amex',
5
- discover: 'Discover',
6
- store: 'store card',
7
- };
1
+ import { NETWORK_NAMES } from "../engine/text.js";
8
2
  export function formatCardInfo(card) {
9
3
  const fee = card.annualFee === null
10
4
  ? ''
@@ -13,6 +7,11 @@ export function formatCardInfo(card) {
13
7
  : `, $${String(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} ($${String(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,5 +1,9 @@
1
- import { feeText, money, pct } from "../engine/text.js";
1
+ import { membershipNote } from "../engine/memberships.js";
2
+ import { feeText, membershipName, money, pct } from "../engine/text.js";
2
3
  const wholeUsd = (n) => money(Math.round(n));
4
+ const membershipText = (memberships) => memberships
5
+ .map((m) => ` + ${wholeUsd(m.annualFee)} for ${m.name.replace(/^an? /, '')}`)
6
+ .join('');
3
7
  const TOP_BEST = 10;
4
8
  export function formatExploration(r, all) {
5
9
  const m = r.merchant;
@@ -20,7 +24,7 @@ export function formatExploration(r, all) {
20
24
  lines.push(` ${c}`);
21
25
  const upgrade = e.withMembership[0];
22
26
  if (upgrade)
23
- lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
27
+ lines.push(` With ${membershipName(upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
24
28
  for (const w of e.warnings)
25
29
  lines.push(` ! ${w}`);
26
30
  });
@@ -64,7 +68,7 @@ export function formatCatalogSimulation(r, catalog, all) {
64
68
  const card = s.cards[0];
65
69
  if (!card)
66
70
  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)}`);
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)}`);
68
72
  });
69
73
  const pairs = r.pairs.slice(0, top);
70
74
  if (pairs.length > 0) {
@@ -92,7 +96,7 @@ export function formatCatalogSimulation(r, catalog, all) {
92
96
  export function formatAdditions(r, catalog, all) {
93
97
  const name = cardNamer(catalog);
94
98
  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` : ''}.`,
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` : ''}.`,
96
100
  ];
97
101
  for (const split of splitText(r.current, name))
98
102
  lines.push(` ${split}`);
@@ -101,14 +105,19 @@ export function formatAdditions(r, catalog, all) {
101
105
  lines.push('', 'No card would add more than its annual fee costs.');
102
106
  }
103
107
  else {
104
- lines.push('', 'Cards that would add the most (after their annual fee):');
108
+ lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
105
109
  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)}`);
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)}`);
107
111
  });
108
112
  }
109
- const notes = new Set(r.current.notes);
113
+ const notes = new Set([
114
+ ...r.current.notes,
115
+ ...gains
116
+ .slice(0, all ? 20 : TOP_SIMULATED)
117
+ .flatMap((a) => a.simulation.memberships.map((m) => membershipNote(catalog, m))),
118
+ ]);
110
119
  if (notes.size > 0)
111
120
  lines.push('', ...notes);
112
- lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
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.');
113
122
  return `${lines.join('\n')}\n`;
114
123
  }
@@ -1,4 +1,4 @@
1
- import { feeText, money } from "../engine/text.js";
1
+ import { feeText, money, shortDate } from "../engine/text.js";
2
2
  const TOP = 10;
3
3
  function reward(o, currencyName) {
4
4
  const amount = o.dollars !== null
@@ -6,12 +6,6 @@ function reward(o, currencyName) {
6
6
  : `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
7
  return o.varies ? `Up to ${amount}` : amount;
8
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
9
  export function formatOffers(c, currencyName, all) {
16
10
  if (c.offers.length === 0) {
17
11
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -25,8 +19,10 @@ export function formatOffers(c, currencyName, all) {
25
19
  const spend = o.spend > 0
26
20
  ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
27
21
  : '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.`);
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.`);
24
+ if (o.ineligible)
25
+ lines.push(` ${o.ineligible}`);
30
26
  if (o.note)
31
27
  lines.push(` ${o.note}`);
32
28
  });
@@ -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
+ cards.forEach((c) => {
32
33
  const earned = c.earned === null ? '' : ` ${cents(c.earned)}`;
33
- lines.push(` ${String(i + 1)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${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)
@@ -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,34 +1,37 @@
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 words = (id) => id.replace(/[-_.]/g, ' ');
1
+ import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
10
2
  export function formatWalletSummary(summary) {
11
3
  const cards = summary.cards.map((c) => {
4
+ const label = c.checkoutLabel
5
+ ? `, shown at checkout as "${c.checkoutLabel}"`
6
+ : '';
12
7
  const lines = [
13
- `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${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)' : ''}.`,
9
+ ` Earns: ${c.rates.join('; ')}.`,
14
10
  ];
15
11
  if (c.onlyAt)
16
12
  lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
17
13
  if (c.chosen) {
18
- const picks = Object.entries(c.chosen.picks)
19
- .map(([choice, options]) => `${words(choice)}: ${options.map(words).join(', ')}`)
14
+ const picks = Object.entries(c.chosen.pickNames)
15
+ .map(([choice, names]) => `${choice}: ${names.join(', ')}`)
20
16
  .join('; ');
21
- lines.push(c.chosen.current
22
- ? ` Picks for ${c.chosen.period}: ${picks}. They need choosing again when the period ends.`
23
- : ` 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.`);
24
25
  }
25
- if (c.activated.length > 0)
26
- lines.push(` Activated: ${c.activated.join(', ')}.`);
26
+ if (c.toActivate.length > 0)
27
+ lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
27
28
  if (c.rotationGap !== null)
28
29
  lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
29
30
  for (const cap of c.caps)
30
31
  if (cap.remaining !== null)
31
- lines.push(` Cap ${cap.key}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}.`);
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}.`);
32
35
  const b = c.bonus;
33
36
  if (b?.state === 'open' && b.spend !== null)
34
37
  lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
@@ -7,7 +7,7 @@ export const fail = (text) => ({
7
7
  content: [{ type: 'text', text }],
8
8
  isError: true,
9
9
  });
10
- export const ALL_CARDS_NOTE = "The user hasn't named their cards, so this covers every card earnrate knows. That's a complete answer. If they want the best of their own cards, they can name them (pass them as `cards`); saving a wallet is optional.";
10
+ export const ALL_CARDS_NOTE = "No cards are saved or named, so this covers every card earnrate knows. That's a complete answer. If the user wants the best of their own cards, they can name them (pass them as `cards`); saving them is optional.";
11
11
  // What every tool shares: the saved wallet, merchant resolution, and which
12
12
  // cards an answer is about.
13
13
  export function makeContext(deps) {
@@ -15,7 +15,9 @@ export function makeContext(deps) {
15
15
  const theWallet = deps.savedWallets === 'file'
16
16
  ? `the wallet at ${deps.store.where}`
17
17
  : 'the saved wallet';
18
- const noWallet = `There's no saved wallet yet${deps.savedWallets === 'file' ? ` (${deps.store.where})` : ''}. Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`;
18
+ const noWallet = deps.savedWallets === 'file'
19
+ ? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
20
+ : "The user's saved wallet has no cards yet. Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.";
19
21
  // Read on every call, so edits to the wallet apply without a restart.
20
22
  async function wallet() {
21
23
  let loaded;
@@ -79,7 +81,9 @@ export function makeContext(deps) {
79
81
  };
80
82
  const text = [
81
83
  message,
82
- `Wallet: ${output.cardIds.join(', ')}.`,
84
+ output.cardIds.length > 0
85
+ ? `Wallet: ${output.cardIds.join(', ')}.`
86
+ : 'The wallet has no cards now; answers cover every card.',
83
87
  ...(notes ? [formatNotes(result.notes)] : []),
84
88
  ].join('\n');
85
89
  return {