earnrate 0.2.5 → 0.2.6

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 (105) hide show
  1. package/README.md +14 -11
  2. package/data/CHANGELOG.md +40 -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 -4
  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 +50 -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/cli/bin.js +10 -1
  63. package/dist/cli/cheatsheet.js +10 -5
  64. package/dist/cli/explore.js +6 -13
  65. package/dist/cli/import.js +2 -2
  66. package/dist/cli/offers.js +17 -7
  67. package/dist/cli/run.js +14 -17
  68. package/dist/cli/wallet.js +8 -5
  69. package/dist/data/file-store.js +6 -7
  70. package/dist/data/integrity.js +4 -0
  71. package/dist/data/load.js +4 -1
  72. package/dist/data/store.js +6 -22
  73. package/dist/data/wallet-edit.js +38 -13
  74. package/dist/engine/cheat-sheet.js +4 -5
  75. package/dist/engine/compare.js +2 -0
  76. package/dist/engine/evaluate.js +2 -4
  77. package/dist/engine/explore.js +4 -36
  78. package/dist/engine/memberships.js +1 -1
  79. package/dist/engine/offers.js +2 -1
  80. package/dist/engine/periods.js +18 -0
  81. package/dist/engine/recommend.js +14 -15
  82. package/dist/engine/setup.js +14 -24
  83. package/dist/engine/simulate.js +7 -6
  84. package/dist/engine/text.js +13 -1
  85. package/dist/engine/wallet-change.js +10 -12
  86. package/dist/engine/wallet-summary.js +64 -28
  87. package/dist/format/card-text.js +7 -20
  88. package/dist/format/explore.js +2 -2
  89. package/dist/format/offers.js +2 -2
  90. package/dist/format/recommendation.js +2 -2
  91. package/dist/format/wallet.js +1 -1
  92. package/dist/import/analyze.js +2 -1
  93. package/dist/mcp/context.js +30 -9
  94. package/dist/mcp/inputs.js +1 -1
  95. package/dist/mcp/schemas.js +1 -0
  96. package/dist/mcp/server.js +7 -1
  97. package/dist/mcp/tools/account.js +44 -16
  98. package/dist/mcp/tools/answers.js +58 -19
  99. package/dist/mcp/tools/wallet.js +58 -24
  100. package/dist/merchants/cache.js +0 -10
  101. package/dist/merchants/resolve.js +81 -4
  102. package/dist/schema/card.js +4 -1
  103. package/dist/schema/issuer.js +8 -0
  104. package/dist/schema/wallet.js +1 -1
  105. package/package.json +1 -1
package/dist/data/load.js CHANGED
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
8
8
  import { CardSchema } from "../schema/card.js";
9
9
  import { CategoriesFileSchema } from "../schema/category.js";
10
10
  import { CurrenciesFileSchema } from "../schema/currency.js";
11
+ import { IssuersFileSchema } from "../schema/issuer.js";
11
12
  import { MembershipsFileSchema } from "../schema/membership.js";
12
13
  import { MerchantsFileSchema } from "../schema/merchant.js";
13
14
  import { WalletSchema } from "../schema/wallet.js";
@@ -31,9 +32,10 @@ async function loadCards(dir) {
31
32
  }));
32
33
  }
33
34
  export async function loadCatalog(dir = DATA_DIR) {
34
- const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
35
+ const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
35
36
  readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
36
37
  readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
38
+ readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
37
39
  readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
38
40
  loadCards(path.join(dir, 'cards')),
39
41
  readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
@@ -42,6 +44,7 @@ export async function loadCatalog(dir = DATA_DIR) {
42
44
  return {
43
45
  categories,
44
46
  currencies,
47
+ issuers,
45
48
  memberships,
46
49
  cards,
47
50
  merchants: { seed, overrides },
@@ -1,7 +1,9 @@
1
1
  import { bookPurchase } from "../engine/record.js";
2
2
  import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
3
- import { WalletSchema } from "../schema/wallet.js";
3
+ import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
4
  const failed = (error) => ({ ok: false, error });
5
+ // Memberships and point values can be saved before any card.
6
+ export const emptyWallet = () => WalletSchema.parse({ version: WALLET_VERSION });
5
7
  // A wallet stored as JSON text. Writes are serialized within this instance;
6
8
  // a backend shared across processes must serialize them itself (one Durable
7
9
  // Object per wallet does).
@@ -30,11 +32,7 @@ export class JsonWalletStore {
30
32
  const change = apply(before);
31
33
  if (!change.ok)
32
34
  return failed(change.error);
33
- // An empty wallet is stored as none: the schema needs a card.
34
- if (change.wallet.cards.length === 0)
35
- await this.backend.remove();
36
- else
37
- await this.backend.write(JSON.stringify(change.wallet));
35
+ await this.backend.write(JSON.stringify(change.wallet));
38
36
  return {
39
37
  ok: true,
40
38
  wallet: change.wallet,
@@ -58,10 +56,10 @@ export class JsonWalletStore {
58
56
  return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
59
57
  }
60
58
  setMemberships(memberships) {
61
- return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
59
+ return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships));
62
60
  }
63
61
  setValuations(changes) {
64
- return this.change(this.needsWallet((w) => withValuations(this.catalog, w, changes)));
62
+ return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
65
63
  }
66
64
  replace(wallet) {
67
65
  return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
@@ -91,17 +89,3 @@ export class JsonWalletStore {
91
89
  });
92
90
  }
93
91
  }
94
- export function memoryBackend(initial = null) {
95
- let text = initial;
96
- return {
97
- read: () => Promise.resolve(text),
98
- write: (next) => {
99
- text = next;
100
- return Promise.resolve();
101
- },
102
- remove: () => {
103
- text = null;
104
- return Promise.resolve();
105
- },
106
- };
107
- }
@@ -93,6 +93,17 @@ function cardsSeq(text) {
93
93
  }
94
94
  return { doc, cards };
95
95
  }
96
+ function rootMap(text) {
97
+ const doc = parseDocument(text);
98
+ if (doc.errors.length > 0)
99
+ throw new Error(doc.errors[0]?.message);
100
+ const root = doc.contents;
101
+ if (!isMap(root))
102
+ throw new Error(`the wallet isn't a map; ${BY_HAND}`);
103
+ return root;
104
+ }
105
+ // A wallet with no cards yet, for memberships or point values saved first.
106
+ export const EMPTY_WALLET_TEXT = `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards: []\n`;
96
107
  function findCard(text, cardId) {
97
108
  const { cards } = cardsSeq(text);
98
109
  const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
@@ -108,6 +119,17 @@ export function addCardText(text, entry) {
108
119
  if (text === null || text.trim() === '') {
109
120
  return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
110
121
  }
122
+ const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
123
+ const value = pair?.value;
124
+ const none = value === null || (isSeq(value) && value.items.length === 0);
125
+ if (!pair || none) {
126
+ const block = `cards:\n${cardLines(entry, ' ')}`;
127
+ if (!pair)
128
+ return insertLines(text, text.length, block);
129
+ const start = lineStart(text, range(pair.key)[0]);
130
+ const end = afterLine(text, range(value ?? pair.key)[1] - 1);
131
+ return text.slice(0, start) + block + text.slice(end);
132
+ }
111
133
  const { cards } = cardsSeq(text);
112
134
  const first = cards.items[0];
113
135
  if (!isMap(first))
@@ -125,8 +147,13 @@ function cardLines(entry, dashIndent) {
125
147
  }
126
148
  export function removeCardText(text, cardId) {
127
149
  const card = findCard(text, cardId);
128
- const start = lineStart(text, range(card)[0]);
129
150
  const end = afterLine(text, range(card)[1] - 1);
151
+ if (cardsSeq(text).cards.items.length === 1) {
152
+ const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
153
+ const start = lineStart(text, range(pair?.key)[0]);
154
+ return `${text.slice(0, start)}cards: []\n${text.slice(end)}`;
155
+ }
156
+ const start = lineStart(text, range(card)[0]);
130
157
  return text.slice(0, start) + text.slice(end);
131
158
  }
132
159
  // Replaces each given field in place, or adds it after the card's last line.
@@ -156,10 +183,7 @@ export function updateCardText(text, cardId, fields) {
156
183
  return out;
157
184
  }
158
185
  export function setMembershipsText(text, memberships) {
159
- const { doc } = cardsSeq(text);
160
- const root = doc.contents;
161
- if (!isMap(root))
162
- throw new Error(`the wallet isn't a map; ${BY_HAND}`);
186
+ const root = rootMap(text);
163
187
  const line = `memberships: ${flowList(memberships)}\n`;
164
188
  const pair = root.items.find((p) => keyOf(p) === 'memberships');
165
189
  if (pair) {
@@ -168,7 +192,9 @@ export function setMembershipsText(text, memberships) {
168
192
  return text.slice(0, start) + line + text.slice(end);
169
193
  }
170
194
  const cards = root.items.find((p) => keyOf(p) === 'cards');
171
- return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
195
+ return cards
196
+ ? insertLines(text, lineStart(text, range(cards.key)[0]), line)
197
+ : insertLines(text, text.length, line);
172
198
  }
173
199
  // Point values, one currency at a time so the rest of the file, comments
174
200
  // included, stays as it was: a value is replaced in place, a new one goes
@@ -192,10 +218,7 @@ function aboveComments(text, at) {
192
218
  return start;
193
219
  }
194
220
  function setValuationText(text, id, cents) {
195
- const { doc } = cardsSeq(text);
196
- const root = doc.contents;
197
- if (!isMap(root))
198
- throw new Error(`the wallet isn't a map; ${BY_HAND}`);
221
+ const root = rootMap(text);
199
222
  const pair = root.items.find((p) => keyOf(p) === 'valuations');
200
223
  const map = pair?.value;
201
224
  const line = cents === null ? '' : `${id}: ${String(cents)}`;
@@ -209,7 +232,9 @@ function setValuationText(text, id, cents) {
209
232
  return text.slice(0, start) + block + text.slice(end);
210
233
  }
211
234
  const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
212
- return insertLines(text, aboveComments(text, range(before?.key)[0]), block);
235
+ return before
236
+ ? insertLines(text, aboveComments(text, range(before.key)[0]), block)
237
+ : insertLines(text, text.length, block);
213
238
  }
214
239
  if (!isMap(map))
215
240
  throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
@@ -251,7 +276,7 @@ function flowUsage(booking) {
251
276
  : booking.period;
252
277
  return `{ period: ${period}, spent: ${String(booking.after)} }`;
253
278
  }
254
- export function capUsageText(text, booking) {
279
+ function capUsageText(text, booking) {
255
280
  const card = findCard(text, booking.cardId);
256
281
  const value = flowUsage(booking);
257
282
  const usage = card.get('cap_usage', true);
@@ -271,7 +296,7 @@ export function capUsageText(text, booking) {
271
296
  const at = afterLine(text, range(card)[1] - 1);
272
297
  return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
273
298
  }
274
- export function bonusSpentText(text, cardId, spent) {
299
+ function bonusSpentText(text, cardId, spent) {
275
300
  const bonus = findCard(text, cardId).get('bonus', true);
276
301
  if (!isMap(bonus))
277
302
  throw new Error(`card '${cardId}' has no bonus`);
@@ -1,11 +1,11 @@
1
- import { resolveMerchantById } from "../merchants/resolve.js";
2
- import { categoryResolution, exploreCards, } from "./explore.js";
1
+ import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
2
+ import { exploreCards } from "./explore.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
4
  import { EPSILON, isClose } from "./compare.js";
5
5
  import { periodKey } from "./periods.js";
6
6
  import { recommend } from "./recommend.js";
7
7
  import { setupNotes } from "./setup.js";
8
- import { feeText, listed, money, shortDate } from "./text.js";
8
+ import { capitalize, feeText, listed, money, shortDate } from "./text.js";
9
9
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
10
10
  // quick picks.
11
11
  export const EVERYDAY_PURCHASES = [
@@ -100,7 +100,6 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
100
100
  // The best card earnrate knows for each everyday kind of purchase, for
101
101
  // someone who hasn't named any cards. Each row's note says what the card
102
102
  // costs or needs.
103
- const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
104
103
  export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
105
104
  const rows = items.map((item) => {
106
105
  const top = exploreCards(catalog, 'merchant' in item
@@ -119,7 +118,7 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
119
118
  channel: null,
120
119
  date,
121
120
  }, options).ranked[0];
122
- const fee = top ? `${capitalized(feeText(top))}.` : null;
121
+ const fee = top ? `${capitalize(feeText(top))}.` : null;
123
122
  return {
124
123
  label: item.label,
125
124
  cardId: top?.cardId ?? null,
@@ -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))
@@ -1,4 +1,4 @@
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";
@@ -6,7 +6,7 @@ import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
8
  import { optionName } from "./wallet-summary.js";
9
- import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./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,
@@ -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,10 @@
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
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  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";
7
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
8
8
  import { cardValuation } from "./valuation.js";
9
9
  export const STALE_AFTER_DAYS = 180;
10
10
  // The warning when no card works; the CLI shows it as the answer instead.
@@ -104,7 +104,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
104
104
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
105
105
  }
106
106
  if (currency !== 'cash') {
107
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${num(entry.effectiveReturnPct)}% back.`);
107
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
108
108
  }
109
109
  return lines;
110
110
  }
@@ -144,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
144
144
  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
145
  }
146
146
  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)}`}.`);
147
+ 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
148
  }
149
149
  return warnings;
150
150
  }
@@ -183,7 +183,7 @@ function tieNote(entries) {
183
183
  tied.some((e) => e.evaluation.best.cap !== null)
184
184
  ? 'because its rate has no cap'
185
185
  : 'by a fixed order; either works';
186
- return `${names} also ${verb} ${num(winner.effectiveReturnPct)}% here. ${displayName(winner)} wins the tie ${why}.`;
186
+ return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
187
187
  }
188
188
  // Finds the closest point inside a currency's value range where the winner
189
189
  // stops winning, varying one currency at a time. Returns are linear in the
@@ -278,19 +278,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
278
278
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
279
279
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
280
280
  if (alt.card.id !== winner.card.id)
281
- warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
281
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
282
282
  else if (twin)
283
- warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
283
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
284
284
  }
285
285
  }
286
286
  if (!winner) {
287
287
  warnings.push(noCardText(merchant.resolvedName));
288
288
  // Only where the merchant's data says what its website takes: without
289
289
  // 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);
290
+ const record = merchant.merchantId === null
291
+ ? undefined
292
+ : findMerchant(catalog, merchant.merchantId);
294
293
  const online = merchant.channel === 'in_store' &&
295
294
  merchant.merchantId !== null &&
296
295
  record?.online?.accepts !== undefined
@@ -300,7 +299,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
300
299
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
301
300
  : undefined;
302
301
  if (alt)
303
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
302
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
304
303
  }
305
304
  else if (merchant.accepts === null &&
306
305
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -325,7 +324,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
325
324
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
326
325
  .entries[0];
327
326
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
328
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
327
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
329
328
  }
330
329
  }
331
330
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -341,7 +340,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
341
340
  const bonus = open && (open.behind || ties) ? open : null;
342
341
  const best = winner?.effectiveReturnPct ?? 0;
343
342
  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.`);
343
+ 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
344
  }
346
345
  const chosen = bonus?.entry ?? winner;
347
346
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -377,7 +376,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
377
376
  : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
378
377
  ? ''
379
378
  : costHere === null
380
- ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
379
+ ? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
381
380
  : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
382
381
  }
383
382
  let confidence = merchant.confidence;
@@ -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) {
@@ -1,13 +1,13 @@
1
- import { resolveMerchant } from "../merchants/resolve.js";
1
+ import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
3
- import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
3
+ import { idealHolding, matchesFilters, } from "./explore.js";
4
4
  import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
5
5
  import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { applyBooking, capBooking } from "./record.js";
7
7
  import { rotationGap } from "./rotation.js";
8
8
  import { money, roundCents } from "./text.js";
9
9
  import { cardValuation } from "./valuation.js";
10
- import { EPSILON } from "./compare.js";
10
+ import { EPSILON, HALF_CENT } from "./compare.js";
11
11
  const MONTHS = 12;
12
12
  const SHORTLIST = 8; // best single cards considered for pairs
13
13
  const PER_LINE = 2; // plus the best cards for each spending line
@@ -16,7 +16,7 @@ export function resolveSpendLines(catalog, lines) {
16
16
  .filter((l) => l.monthly > 0)
17
17
  .map((l) => {
18
18
  const merchant = l.merchant !== null
19
- ? resolveMerchant(catalog, l.merchant, null)
19
+ ? resolvePlace(catalog, l.merchant, null)
20
20
  : categoryResolution(catalog, l.category ?? 'other', null);
21
21
  return { label: merchant.resolvedName, monthly: l.monthly, merchant };
22
22
  });
@@ -224,7 +224,7 @@ function foldTies(pairs) {
224
224
  const folded = [];
225
225
  for (const pair of pairs) {
226
226
  const [a, b] = pair.cards;
227
- const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
227
+ const twin = folded.find((f) => Math.abs(f.net - pair.net) < HALF_CENT &&
228
228
  costs(f) === costs(pair) &&
229
229
  f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
230
230
  if (!twin) {
@@ -339,7 +339,8 @@ export function spendLineFor(catalog, what, monthly) {
339
339
  const category = findCategory(catalog, what);
340
340
  return { merchant: category === null ? what : null, category, monthly };
341
341
  }
342
+ export const typicalSpend = (catalog) => TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly));
342
343
  export function typicalSpendLines(catalog) {
343
- return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
344
+ return resolveSpendLines(catalog, typicalSpend(catalog));
344
345
  }
345
346
  export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
@@ -2,6 +2,18 @@ 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
+ // Ids for a message: "amex-gold, citi-double-cash".
6
+ export const idList = (ids) => ids.join(', ');
7
+ export const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
8
+ // "usbank.com" from "https://www.usbank.com/credit-cards/...".
9
+ export function hostname(url) {
10
+ try {
11
+ return new URL(url).hostname.replace(/^www\./, '');
12
+ }
13
+ catch {
14
+ return url;
15
+ }
16
+ }
5
17
  // An id in words: "gas-ev-charging" reads "gas ev charging".
6
18
  export const words = (id) => id.replace(/[-_.]/g, ' ');
7
19
  const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
@@ -47,7 +59,7 @@ export function categoryPhrase(catalog, id) {
47
59
  }
48
60
  export function earnText(catalog, rate, currency) {
49
61
  return currency === 'cash'
50
- ? `${num(rate)}% cash back`
62
+ ? `${pct(rate)} cash back`
51
63
  : `${num(rate)}x ${currencyName(catalog, currency)}`;
52
64
  }
53
65
  export function codesAs(catalog, merchant) {