earnrate 0.2.5 → 0.2.7

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 (112) hide show
  1. package/README.md +25 -18
  2. package/data/CHANGELOG.md +56 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +14 -6
  5. package/data/cards/amex-blue-cash-everyday.yaml +12 -3
  6. package/data/cards/amex-blue-cash-preferred.yaml +17 -3
  7. package/data/cards/amex-gold.yaml +19 -3
  8. package/data/cards/amex-platinum.yaml +19 -3
  9. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
  10. package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
  11. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
  12. package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
  13. package/data/cards/bilt-obsidian.yaml +4 -3
  14. package/data/cards/capital-one-kohls-card.yaml +11 -2
  15. package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
  16. package/data/cards/capital-one-savor.yaml +32 -10
  17. package/data/cards/capital-one-venture-x.yaml +19 -4
  18. package/data/cards/capital-one-venture.yaml +19 -5
  19. package/data/cards/chase-aeroplan.yaml +15 -3
  20. package/data/cards/chase-freedom-flex.yaml +49 -10
  21. package/data/cards/chase-freedom-unlimited.yaml +6 -2
  22. package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
  23. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
  24. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
  25. package/data/cards/chase-ihg-one-rewards.yaml +8 -2
  26. package/data/cards/chase-ink-business-cash.yaml +7 -3
  27. package/data/cards/chase-ink-business-preferred.yaml +9 -2
  28. package/data/cards/chase-prime-visa.yaml +24 -5
  29. package/data/cards/chase-sapphire-preferred.yaml +59 -6
  30. package/data/cards/chase-sapphire-reserve.yaml +12 -2
  31. package/data/cards/chase-southwest-performance-business.yaml +22 -4
  32. package/data/cards/chase-southwest-plus.yaml +13 -3
  33. package/data/cards/chase-southwest-premier-business.yaml +22 -5
  34. package/data/cards/chase-southwest-premier.yaml +13 -3
  35. package/data/cards/chase-southwest-priority.yaml +18 -2
  36. package/data/cards/chase-united-business.yaml +5 -2
  37. package/data/cards/chase-united-club-business.yaml +2 -2
  38. package/data/cards/chase-united-club.yaml +16 -3
  39. package/data/cards/chase-united-explorer.yaml +5 -2
  40. package/data/cards/chase-united-gateway.yaml +2 -2
  41. package/data/cards/chase-united-quest.yaml +12 -3
  42. package/data/cards/citi-aadvantage-business.yaml +10 -4
  43. package/data/cards/citi-aadvantage-executive.yaml +8 -8
  44. package/data/cards/citi-aadvantage-mileup.yaml +7 -4
  45. package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
  46. package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
  47. package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
  48. package/data/cards/citi-custom-cash.yaml +43 -12
  49. package/data/cards/citi-double-cash.yaml +9 -3
  50. package/data/cards/citi-my-best-buy-visa.yaml +6 -3
  51. package/data/cards/citi-strata-elite.yaml +12 -2
  52. package/data/cards/citi-strata-premier.yaml +24 -2
  53. package/data/cards/sofi-unlimited-2.yaml +17 -3
  54. package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
  55. package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
  56. package/data/cards/td-bank-target-circle-card.yaml +13 -4
  57. package/data/cards/usbank-cash-plus.yaml +29 -7
  58. package/data/cards/wells-fargo-active-cash.yaml +9 -2
  59. package/data/cards/wells-fargo-autograph.yaml +31 -7
  60. package/data/issuers.yaml +32 -0
  61. package/data/merchants/seed.yaml +7 -0
  62. package/dist/brand.js +1 -0
  63. package/dist/cli/bin.js +10 -1
  64. package/dist/cli/cheatsheet.js +10 -5
  65. package/dist/cli/explore.js +6 -13
  66. package/dist/cli/import.js +2 -2
  67. package/dist/cli/missed.js +65 -0
  68. package/dist/cli/offers.js +17 -7
  69. package/dist/cli/run.js +29 -17
  70. package/dist/cli/todo.js +62 -0
  71. package/dist/cli/wallet.js +9 -6
  72. package/dist/data/file-store.js +6 -7
  73. package/dist/data/integrity.js +4 -0
  74. package/dist/data/load.js +4 -1
  75. package/dist/data/store.js +6 -22
  76. package/dist/data/wallet-edit.js +38 -13
  77. package/dist/engine/cheat-sheet.js +14 -6
  78. package/dist/engine/checklist.js +152 -0
  79. package/dist/engine/compare.js +2 -0
  80. package/dist/engine/evaluate.js +16 -11
  81. package/dist/engine/explore.js +6 -38
  82. package/dist/engine/memberships.js +1 -1
  83. package/dist/engine/offers.js +2 -1
  84. package/dist/engine/periods.js +18 -0
  85. package/dist/engine/recommend.js +21 -20
  86. package/dist/engine/rule-text.js +47 -0
  87. package/dist/engine/setup.js +14 -24
  88. package/dist/engine/simulate.js +7 -6
  89. package/dist/engine/text.js +13 -1
  90. package/dist/engine/wallet-change.js +10 -12
  91. package/dist/engine/wallet-summary.js +80 -65
  92. package/dist/format/card-text.js +7 -20
  93. package/dist/format/explore.js +2 -2
  94. package/dist/format/offers.js +2 -2
  95. package/dist/format/recommendation.js +2 -2
  96. package/dist/format/wallet.js +7 -1
  97. package/dist/import/analyze.js +2 -1
  98. package/dist/mcp/context.js +38 -13
  99. package/dist/mcp/inputs.js +1 -1
  100. package/dist/mcp/schemas.js +9 -0
  101. package/dist/mcp/serve.js +4 -0
  102. package/dist/mcp/server.js +8 -2
  103. package/dist/mcp/tools/account.js +44 -16
  104. package/dist/mcp/tools/answers.js +58 -19
  105. package/dist/mcp/tools/wallet.js +64 -25
  106. package/dist/merchants/cache.js +0 -10
  107. package/dist/merchants/missed.js +78 -0
  108. package/dist/merchants/resolve.js +81 -4
  109. package/dist/schema/card.js +4 -1
  110. package/dist/schema/issuer.js +8 -0
  111. package/dist/schema/wallet.js +1 -1
  112. package/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ import { daysBetween, periodEnd, periodKey } from "./periods.js";
2
+ import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
3
+ import { optionName } from "./rule-text.js";
4
+ import { activationsDue, walletSummary, } from "./wallet-summary.js";
5
+ // What the wallet's cards need this period, soonest first: bonuses to
6
+ // activate, categories to pick, caps running low, welcome bonuses to finish,
7
+ // and rotating categories the issuer hasn't posted yet.
8
+ export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
9
+ // A shorter card name than the issuer's full one, where the caller has it.
10
+ shortName) {
11
+ const items = [];
12
+ for (const held of wallet.cards) {
13
+ const card = catalog.cards.find((c) => c.id === held.id);
14
+ const s = summary.cards.find((c) => c.cardId === held.id);
15
+ if (!card || !s)
16
+ continue;
17
+ const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
18
+ for (const a of activationsDue(catalog, card, held, today)) {
19
+ const starts = a.from !== null && a.from > today
20
+ ? `, starting ${shortDate(a.from)}`
21
+ : '';
22
+ items.push({
23
+ kind: 'activate',
24
+ cardId: card.id,
25
+ text: `Activate ${name}'s bonus on ${a.what}${starts}${a.activateBy === null ? '' : `, by ${shortDate(a.activateBy)}`}.`,
26
+ due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
27
+ });
28
+ }
29
+ const picked = (id) => s.chosen?.picks[id] !== undefined;
30
+ const current = s.chosen?.current === true;
31
+ const unpicked = card.choices.filter((c) => !c.default &&
32
+ !(current && picked(c.id)) &&
33
+ c.period !== 'until_changed');
34
+ const [first] = unpicked;
35
+ if (first) {
36
+ const period = first.period === 'until_changed'
37
+ ? null
38
+ : periodKey(first.period, today, null);
39
+ items.push({
40
+ kind: 'pick',
41
+ cardId: card.id,
42
+ text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
43
+ due: period === null ? null : periodEnd(period),
44
+ });
45
+ }
46
+ else {
47
+ // Picks made for this period: near its end, a reminder to make the
48
+ // next period's (Cash+ asks each quarter).
49
+ const ending = card.choices.find((c) => c.period !== 'until_changed' && !c.default && current);
50
+ const period = ending === undefined || ending.period === 'until_changed'
51
+ ? null
52
+ : periodKey(ending.period, today, null);
53
+ const end = period === null ? null : periodEnd(period);
54
+ if (ending && end !== null && daysBetween(today, end) <= 14)
55
+ items.push({
56
+ kind: 'pick',
57
+ cardId: card.id,
58
+ text: `Pick ${name}'s categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
59
+ due: end,
60
+ });
61
+ }
62
+ // A pick earnrate assumes because none was saved: worth confirming.
63
+ const assumed = card.choices.filter((c) => c.default && !picked(c.id));
64
+ if (assumed.length > 0)
65
+ items.push({
66
+ kind: 'pick',
67
+ cardId: card.id,
68
+ text: `${name}'s ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
69
+ due: null,
70
+ });
71
+ for (const cap of s.caps) {
72
+ // Only caps nearly used up: the rest aren't something to do.
73
+ if (cap.remaining === null || cap.remaining > cap.amount * 0.25)
74
+ continue;
75
+ const resets = cap.currentPeriod === null ? null : periodEnd(cap.currentPeriod);
76
+ items.push({
77
+ kind: 'cap',
78
+ cardId: card.id,
79
+ text: cap.remaining === 0
80
+ ? `${name}'s ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
81
+ : `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
82
+ due: resets,
83
+ });
84
+ }
85
+ const b = s.bonus;
86
+ if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
87
+ const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
88
+ items.push({
89
+ kind: 'bonus',
90
+ cardId: card.id,
91
+ text: `${name}'s welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
92
+ due: b.deadline,
93
+ });
94
+ }
95
+ if (s.rotationGap !== null) {
96
+ items.push({
97
+ kind: 'rotation',
98
+ cardId: card.id,
99
+ text: `${name}'s ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
100
+ due: null,
101
+ });
102
+ }
103
+ }
104
+ return items.sort((a, b) => (a.due ?? '9999').localeCompare(b.due ?? '9999') ||
105
+ a.cardId.localeCompare(b.cardId));
106
+ }
107
+ // An all-day calendar event for each dated action (activations, picks,
108
+ // welcome-bonus deadlines), with a reminder two days before. Built on the
109
+ // device: nothing is sent anywhere.
110
+ export function checklistCalendar(items, stamp) {
111
+ const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
112
+ const fold = (line) => {
113
+ const parts = [];
114
+ for (let i = 0; i < line.length; i += 73)
115
+ parts.push(line.slice(i, i + 73));
116
+ return parts.join('\r\n ');
117
+ };
118
+ const day = (d) => d.replace(/-/g, '');
119
+ const next = (d) => {
120
+ const t = new Date(`${d}T00:00:00Z`);
121
+ t.setUTCDate(t.getUTCDate() + 1);
122
+ return t.toISOString().slice(0, 10);
123
+ };
124
+ const events = items
125
+ .filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
126
+ .flatMap((i) => {
127
+ const due = i.due ?? '';
128
+ return [
129
+ 'BEGIN:VEVENT',
130
+ `UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
131
+ `DTSTAMP:${stamp}`,
132
+ `DTSTART;VALUE=DATE:${day(due)}`,
133
+ `DTEND;VALUE=DATE:${day(next(due))}`,
134
+ fold(`SUMMARY:${escape(i.text)}`),
135
+ 'BEGIN:VALARM',
136
+ 'TRIGGER:-P2D',
137
+ 'ACTION:DISPLAY',
138
+ fold(`DESCRIPTION:${escape(i.text)}`),
139
+ 'END:VALARM',
140
+ 'END:VEVENT',
141
+ ];
142
+ });
143
+ return [
144
+ 'BEGIN:VCALENDAR',
145
+ 'VERSION:2.0',
146
+ 'PRODID:-//earnrate//checklist//EN',
147
+ 'CALSCALE:GREGORIAN',
148
+ ...events,
149
+ 'END:VCALENDAR',
150
+ '',
151
+ ].join('\r\n');
152
+ }
@@ -1,5 +1,7 @@
1
1
  // Returns closer than this are equal: floating-point noise, not a difference.
2
2
  export const EPSILON = 1e-9;
3
+ // Dollar amounts closer than half a cent print the same.
4
+ export const HALF_CENT = 0.005;
3
5
  // A card close to the best one: within a percentage point, and at least
4
6
  // three quarters of its rate. Answers and cheat sheets mention a welcome
5
7
  // bonus card when it's this close.
@@ -1,4 +1,4 @@
1
- import { periodKey } from "./periods.js";
1
+ import { inEffect, periodKey } from "./periods.js";
2
2
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
3
3
  return {
4
4
  category,
@@ -110,9 +110,7 @@ function applicability(card, rule, ctx, held, picked) {
110
110
  return 'no';
111
111
  if (rule.geography === 'us_only' && ctx.country !== 'US')
112
112
  return 'no';
113
- if (rule.valid_from !== undefined && ctx.date < rule.valid_from)
114
- return 'no';
115
- if (rule.valid_to !== undefined && ctx.date > rule.valid_to)
113
+ if (!inEffect(rule, ctx.date))
116
114
  return 'no';
117
115
  const membership = rule.requires?.membership;
118
116
  if (membership !== undefined && !ctx.memberships.has(membership))
@@ -149,14 +147,14 @@ function capState(rule, card, held, date) {
149
147
  const { period, spent } = capUsed(cap, key, held, date);
150
148
  return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
151
149
  }
152
- function rateAfterCap(rate, cap, amount) {
150
+ function rateAfterCap(rate, cap, afterCap, amount) {
153
151
  if (!cap)
154
152
  return rate;
155
153
  if (amount !== null && amount > 0) {
156
154
  const inCap = Math.min(amount, cap.remaining);
157
- return (inCap * rate + (amount - inCap) * cap.cap.then_rate) / amount;
155
+ return (inCap * rate + (amount - inCap) * afterCap) / amount;
158
156
  }
159
- return cap.remaining > 0 ? rate : cap.cap.then_rate;
157
+ return cap.remaining > 0 ? rate : afterCap;
160
158
  }
161
159
  // On equal rates an uncapped rule wins, so spend that can earn the same
162
160
  // without a cap doesn't use one up. Otherwise the earlier rule stays.
@@ -172,24 +170,31 @@ export function evaluateCard(card, held, ctx) {
172
170
  nominalRate: card.base_rate,
173
171
  rate: card.base_rate,
174
172
  cap: null,
173
+ afterCap: null,
175
174
  };
176
175
  let best = base;
177
176
  let nominal = base;
178
177
  const blocked = [];
178
+ const applying = [];
179
179
  for (const rule of card.rules) {
180
180
  const status = applicability(card, rule, ctx, held, picked);
181
181
  if (status === 'no')
182
182
  continue;
183
- if (status === 'needs-activation') {
183
+ if (status === 'needs-activation')
184
184
  blocked.push(rule);
185
- continue;
186
- }
185
+ else
186
+ applying.push(rule);
187
+ }
188
+ const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
189
+ for (const rule of applying) {
187
190
  const cap = capState(rule, card, held, ctx.date);
191
+ const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
188
192
  const earning = {
189
193
  rule,
190
194
  nominalRate: rule.rate,
191
- rate: rateAfterCap(rule.rate, cap, ctx.amount),
195
+ rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
192
196
  cap,
197
+ afterCap,
193
198
  };
194
199
  if (beats(earning, earning.rate, best, best.rate))
195
200
  best = earning;
@@ -1,12 +1,12 @@
1
- import { resolveMerchant } from "../merchants/resolve.js";
1
+ import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
3
  import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
4
4
  import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
- import { optionName } from "./wallet-summary.js";
9
- import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
8
+ import { optionName } from "./rule-text.js";
9
+ import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
10
10
  import { cardValuation } from "./valuation.js";
11
11
  import { EPSILON } from "./compare.js";
12
12
  export const ALL_PERSONAL_CARDS = {
@@ -49,38 +49,6 @@ export function idealHolding(card, date, picks = {}) {
49
49
  : {}),
50
50
  };
51
51
  }
52
- const plainWords = (s) => s
53
- .toLowerCase()
54
- .replace(/[^a-z0-9]+/g, ' ')
55
- .trim();
56
- // A category from everyday words: an alias ("groceries") first, then the
57
- // name, then the id, so "grocery" means supermarkets rather than the parent.
58
- export function findCategory(catalog, text) {
59
- const wanted = plainWords(text);
60
- if (wanted === '')
61
- return null;
62
- const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
63
- return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
64
- }
65
- export function categoryResolution(catalog, category, channel) {
66
- const found = catalog.categories.find((c) => c.id === category);
67
- if (!found)
68
- throw new Error(`unknown category '${category}'`);
69
- return {
70
- input: found.name,
71
- merchantId: null,
72
- resolvedName: found.name,
73
- category,
74
- likelyMcc: null,
75
- accepts: null,
76
- ambiguity: null,
77
- channel,
78
- confidence: 'high',
79
- source: 'category',
80
- bookedVia: [],
81
- inference: null,
82
- };
83
- }
84
52
  function walletOf(held, options) {
85
53
  return {
86
54
  valuations: options.valuations,
@@ -131,7 +99,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
131
99
  ? {
132
100
  amount: cap.amount,
133
101
  period: cap.period,
134
- thenRate: cap.then_rate,
102
+ thenRate: best.afterCap ?? cap.then_rate,
135
103
  currency: cardValuation(catalog, wallet, card).currency,
136
104
  }
137
105
  : null,
@@ -185,7 +153,7 @@ function compareExplored(a, b) {
185
153
  export function exploreCards(catalog, query, options, resolved) {
186
154
  const merchant = resolved ??
187
155
  (query.merchant !== null
188
- ? resolveMerchant(catalog, query.merchant, query.channel)
156
+ ? resolvePlace(catalog, query.merchant, query.channel)
189
157
  : categoryResolution(catalog, query.category ?? 'other', query.channel));
190
158
  const purchase = {
191
159
  merchant: merchant.input,
@@ -259,7 +227,7 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
259
227
  assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
260
228
  assumptions.push(options.memberships.length === 0
261
229
  ? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
262
- : `Assumes these memberships: ${options.memberships.join(', ')}.`);
230
+ : `Assumes these memberships: ${listed(options.memberships.map((m) => membershipName(catalog, m)))}.`);
263
231
  const values = pointValuesText(catalog, ranked.slice(0, 10));
264
232
  if (values !== null)
265
233
  assumptions.push(values);
@@ -6,7 +6,7 @@ export function membershipName(catalog, id) {
6
6
  return membership(catalog, id)?.name ?? words(id);
7
7
  }
8
8
  // "a Costco membership", for a sentence.
9
- export function withArticle(name) {
9
+ function withArticle(name) {
10
10
  return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
11
11
  }
12
12
  // Tiers of one membership: holding one, the others in its group aren't
@@ -1,6 +1,7 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
+ import { inEffect } from "./periods.js";
4
5
  import { money, roundCents } from "./text.js";
5
6
  import { cardValuation } from "./valuation.js";
6
7
  function ineligibleText(catalog, wallet, notWith) {
@@ -41,7 +42,7 @@ export function compareOffers(catalog, wallet, filters, today) {
41
42
  if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
42
43
  continue;
43
44
  const offer = card.welcome_offer;
44
- if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
45
+ if (!offer || !inEffect(offer, today)) {
45
46
  withoutOffer.push(card.id);
46
47
  continue;
47
48
  }
@@ -38,3 +38,21 @@ export function choicePeriod(card) {
38
38
  ];
39
39
  return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
40
40
  }
41
+ // Whether a dated rule or offer is in effect on `date`; an open end has no
42
+ // limit on that side.
43
+ export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
44
+ (window.valid_to === undefined || window.valid_to >= date);
45
+ // The last day of a period key ("2026-Q4", "2026-10", "2026").
46
+ export function periodEnd(period) {
47
+ const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
48
+ if (quarter) {
49
+ const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
50
+ return `${quarter[1] ?? ''}-${end ?? ''}`;
51
+ }
52
+ const month = /^(\d{4})-(\d{2})$/.exec(period);
53
+ if (month) {
54
+ const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
55
+ return last.toISOString().slice(0, 10);
56
+ }
57
+ return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
58
+ }
@@ -1,10 +1,12 @@
1
- import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
1
+ import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { membershipName } from "./memberships.js";
4
5
  import { daysBetween } from "./periods.js";
6
+ import { ruleWhat } from "./rule-text.js";
5
7
  import { rotationGap } from "./rotation.js";
6
8
  import { EPSILON, isClose } from "./compare.js";
7
- import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
9
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
8
10
  import { cardValuation } from "./valuation.js";
9
11
  export const STALE_AFTER_DAYS = 180;
10
12
  // The warning when no card works; the CLI shows it as the answer instead.
@@ -84,7 +86,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
84
86
  const currency = valuation.currency;
85
87
  if (best.rule) {
86
88
  const note = best.rule.note ? ` ${best.rule.note}` : '';
87
- lines.push(`${earnText(catalog, best.nominalRate, currency)} from its "${best.rule.id}" rule.${note}`);
89
+ lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
88
90
  }
89
91
  else {
90
92
  lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
@@ -92,7 +94,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
92
94
  const cap = best.cap;
93
95
  if (cap && amount !== null && amount > cap.remaining) {
94
96
  const over = amount - cap.remaining;
95
- lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
97
+ lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
96
98
  }
97
99
  if (valuation.pooledFrom !== null) {
98
100
  const partners = card.pools_into?.when_wallet_has ?? [];
@@ -104,7 +106,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
104
106
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
105
107
  }
106
108
  if (currency !== 'cash') {
107
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${num(entry.effectiveReturnPct)}% back.`);
109
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
108
110
  }
109
111
  return lines;
110
112
  }
@@ -121,17 +123,17 @@ function warningsFor(catalog, entry, purchase) {
121
123
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
122
124
  }
123
125
  else if (purchase.amount !== null && purchase.amount > remaining) {
124
- warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
126
+ warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
125
127
  }
126
128
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
127
- warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
129
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
128
130
  }
129
131
  }
130
132
  if (nominal.rule &&
131
133
  nominal.cap &&
132
134
  nominal !== best &&
133
135
  nominal.cap.remaining === 0) {
134
- warnings.push(`The ${capLabel(nominal)} on "${nominal.rule.id}" is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
136
+ warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
135
137
  }
136
138
  if (best.rule?.unverified !== undefined) {
137
139
  warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
@@ -144,7 +146,7 @@ function warningsFor(catalog, entry, purchase) {
144
146
  warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
145
147
  }
146
148
  for (const rule of evaluation.needsActivation) {
147
- warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
149
+ warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
148
150
  }
149
151
  return warnings;
150
152
  }
@@ -183,7 +185,7 @@ function tieNote(entries) {
183
185
  tied.some((e) => e.evaluation.best.cap !== null)
184
186
  ? 'because its rate has no cap'
185
187
  : 'by a fixed order; either works';
186
- return `${names} also ${verb} ${num(winner.effectiveReturnPct)}% here. ${displayName(winner)} wins the tie ${why}.`;
188
+ return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
187
189
  }
188
190
  // Finds the closest point inside a currency's value range where the winner
189
191
  // stops winning, varying one currency at a time. Returns are linear in the
@@ -278,19 +280,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
278
280
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
279
281
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
280
282
  if (alt.card.id !== winner.card.id)
281
- warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
283
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
282
284
  else if (twin)
283
- warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
285
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
284
286
  }
285
287
  }
286
288
  if (!winner) {
287
289
  warnings.push(noCardText(merchant.resolvedName));
288
290
  // Only where the merchant's data says what its website takes: without
289
291
  // that, every network is assumed, and a gas pump has no website.
290
- const record = [
291
- ...catalog.merchants.overrides,
292
- ...catalog.merchants.seed,
293
- ].find((m) => m.id === merchant.merchantId);
292
+ const record = merchant.merchantId === null
293
+ ? undefined
294
+ : findMerchant(catalog, merchant.merchantId);
294
295
  const online = merchant.channel === 'in_store' &&
295
296
  merchant.merchantId !== null &&
296
297
  record?.online?.accepts !== undefined
@@ -300,7 +301,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
300
301
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
301
302
  : undefined;
302
303
  if (alt)
303
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
304
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
304
305
  }
305
306
  else if (merchant.accepts === null &&
306
307
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -325,7 +326,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
325
326
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
326
327
  .entries[0];
327
328
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
328
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
329
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
329
330
  }
330
331
  }
331
332
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -341,7 +342,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
341
342
  const bonus = open && (open.behind || ties) ? open : null;
342
343
  const best = winner?.effectiveReturnPct ?? 0;
343
344
  if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
344
- notes.push(`${displayName(open.entry)} earns ${num(open.entry.effectiveReturnPct)}% here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${num(best)}%, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
345
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
345
346
  }
346
347
  const chosen = bonus?.entry ?? winner;
347
348
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -377,7 +378,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
377
378
  : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
378
379
  ? ''
379
380
  : costHere === null
380
- ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
381
+ ? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
381
382
  : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
382
383
  }
383
384
  let confidence = merchant.confidence;
@@ -0,0 +1,47 @@
1
+ import { findMerchant } from "../merchants/resolve.js";
2
+ import { categoryPhrase, choiceName, listed, words } from "./text.js";
3
+ // What a rule or a pick covers, in words, for answers and wallet lists.
4
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
5
+ // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
6
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
7
+ function merchantName(catalog, id) {
8
+ const merchant = findMerchant(catalog, id);
9
+ const name = merchant?.name ?? words(id);
10
+ return merchant?.kind === 'booking'
11
+ ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
12
+ : name;
13
+ }
14
+ export function optionName(catalog, card, choiceId, option) {
15
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
16
+ if (!o)
17
+ return words(option);
18
+ const parts = [
19
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
20
+ ...o.merchants.map((m) => merchantName(catalog, m)),
21
+ ];
22
+ if (o.online)
23
+ parts.push('online shopping');
24
+ return parts.length > 0 ? listed(parts) : words(option);
25
+ }
26
+ // What a rule covers, as a list of phrases.
27
+ export function ruleParts(catalog, rule) {
28
+ if (rule.match.any)
29
+ return ['everything else'];
30
+ if (rule.match.choice !== undefined)
31
+ return [`your ${choiceName(rule.match.choice)} picks`];
32
+ const merchants = rule.match.merchants ?? [];
33
+ // A long list of one kind of store reads as "select streaming services".
34
+ const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
35
+ const [kind] = kinds;
36
+ const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
37
+ ? [`select ${categoryPhrase(catalog, kind)}`]
38
+ : merchants.map((m) => merchantName(catalog, m));
39
+ const parts = [
40
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
41
+ ...merchantParts,
42
+ ];
43
+ return parts.length > 0 ? parts : [words(rule.id)];
44
+ }
45
+ export function ruleWhat(catalog, rule) {
46
+ return listed(ruleParts(catalog, rule));
47
+ }
@@ -2,19 +2,10 @@ import { isActivated, pickedCategories } from "./evaluate.js";
2
2
  import { membership, membershipName, sameGroup } from "./memberships.js";
3
3
  import { daysBetween } from "./periods.js";
4
4
  import { rotationGap } from "./rotation.js";
5
+ import { idList, listed } from "./text.js";
5
6
  // Rules that start within this many days are worth activating now.
6
7
  const ACTIVATION_LOOKAHEAD_DAYS = 45;
7
- const ISSUER_NICKNAMES = {
8
- amex: 'american express',
9
- 'bank-of-america': 'bofa boa bankofamerica',
10
- 'capital-one': 'capitalone',
11
- citi: 'citibank',
12
- usbank: 'us bank',
13
- 'wells-fargo': 'wf wellsfargo',
14
- 'td-bank': 'td',
15
- column: 'bilt',
16
- };
17
- const words = (s) => s
8
+ const searchWords = (s) => s
18
9
  .toLowerCase()
19
10
  .replace(/\+/g, ' plus ')
20
11
  .replace(/&/g, ' and ')
@@ -33,12 +24,12 @@ function editDistance(a, b) {
33
24
  }
34
25
  return row[b.length] ?? 0;
35
26
  }
36
- function matcher(card) {
27
+ function matcher(catalog, card) {
37
28
  const haystack = [
38
- ...words(card.id),
39
- ...words(card.name),
40
- ...words(card.issuer),
41
- ...words(ISSUER_NICKNAMES[card.issuer] ?? ''),
29
+ ...searchWords(card.id),
30
+ ...searchWords(card.name),
31
+ ...searchWords(card.issuer),
32
+ ...(catalog.issuers.find((i) => i.id === card.issuer)?.search ?? []).flatMap(searchWords),
42
33
  ];
43
34
  const compact = haystack.join('');
44
35
  return (token) => haystack.some((w) => w.startsWith(token)) ||
@@ -108,12 +99,12 @@ export function cardInfo(catalog, card, wallet, today) {
108
99
  // issuer (as a prefix, or with a typo or two in a long word). With no full
109
100
  // match, the cards matching the most words come back instead.
110
101
  export function searchCards(catalog, query, wallet, today) {
111
- const tokens = words(query);
102
+ const tokens = searchWords(query);
112
103
  const scored = catalog.cards.map((card) => {
113
- const matches = matcher(card);
104
+ const matches = matcher(catalog, card);
114
105
  return { card, score: tokens.filter((t) => matches(t)).length };
115
106
  });
116
- const byShortestId = (a, b) => words(a.card.id).length - words(b.card.id).length ||
107
+ const byShortestId = (a, b) => searchWords(a.card.id).length - searchWords(b.card.id).length ||
117
108
  a.card.id.localeCompare(b.card.id);
118
109
  const full = scored.filter((s) => s.score === tokens.length);
119
110
  const best = Math.max(0, ...scored.map((s) => s.score));
@@ -131,7 +122,6 @@ export function searchCards(catalog, query, wallet, today) {
131
122
  exact,
132
123
  };
133
124
  }
134
- const list = (ids) => ids.join(', ');
135
125
  // What a card in the wallet still needs before its answers are right.
136
126
  export function setupNotes(catalog, wallet, held, today) {
137
127
  const card = catalog.cards.find((c) => c.id === held.id);
@@ -145,11 +135,11 @@ export function setupNotes(catalog, wallet, held, today) {
145
135
  (staleChoice === null || choice.period === 'until_changed');
146
136
  if (current)
147
137
  continue;
148
- const options = list(Object.keys(choice.options));
138
+ const options = idList(Object.keys(choice.options));
149
139
  notes.push({
150
140
  kind: 'pick',
151
141
  text: choice.default && picked.has(choice.id)
152
- ? `${name}'s ${choice.id} pick is assumed to be ${list(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
142
+ ? `${name}'s ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
153
143
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
154
144
  });
155
145
  }
@@ -158,14 +148,14 @@ export function setupNotes(catalog, wallet, held, today) {
158
148
  if (missing.length > 0) {
159
149
  notes.push({
160
150
  kind: 'membership',
161
- text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
151
+ text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
162
152
  });
163
153
  }
164
154
  const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
165
155
  if (toActivate.length > 0) {
166
156
  notes.push({
167
157
  kind: 'activation',
168
- text: `${name} has rates that apply only once activated with the issuer: ${list(toActivate)}. Mark the ones already activated.`,
158
+ text: `${name} has rates that apply only once activated with the issuer: ${idList(toActivate)}. Mark the ones already activated.`,
169
159
  });
170
160
  }
171
161
  if (needsAnniversary(card) && held.anniversary === undefined) {