earnrate 0.2.14 → 0.2.16

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 (45) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +12 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  5. package/data/cards/discover-it-cash-back.yaml +1 -1
  6. package/data/cards/venmo-credit-card.yaml +1 -1
  7. package/data/categories.yaml +10 -0
  8. package/dist/cli/args.js +30 -1
  9. package/dist/cli/import.js +14 -11
  10. package/dist/cli/run.js +10 -1
  11. package/dist/cli/todo.js +3 -2
  12. package/dist/cli/wallet.js +49 -16
  13. package/dist/engine/cheat-sheet.js +13 -10
  14. package/dist/engine/checklist.js +5 -10
  15. package/dist/engine/credits.js +2 -2
  16. package/dist/engine/evaluate.js +1 -1
  17. package/dist/engine/explore.js +7 -3
  18. package/dist/engine/intro.js +1 -5
  19. package/dist/engine/memberships.js +3 -7
  20. package/dist/engine/offers.js +3 -5
  21. package/dist/engine/periods.js +5 -0
  22. package/dist/engine/profiles.js +2 -1
  23. package/dist/engine/recommend.js +19 -17
  24. package/dist/engine/record.js +1 -1
  25. package/dist/engine/rule-text.js +71 -23
  26. package/dist/engine/setup.js +4 -0
  27. package/dist/engine/text.js +23 -6
  28. package/dist/engine/wallet-change.js +5 -5
  29. package/dist/engine/wallet-summary.js +32 -23
  30. package/dist/format/card-text.js +6 -5
  31. package/dist/format/cards.js +2 -2
  32. package/dist/format/explore.js +2 -2
  33. package/dist/format/wallet-yaml.js +2 -2
  34. package/dist/format/wallet.js +5 -5
  35. package/dist/import/analyze.js +13 -1
  36. package/dist/mcp/context.js +2 -3
  37. package/dist/mcp/inputs.js +11 -3
  38. package/dist/mcp/schemas.js +2 -0
  39. package/dist/mcp/server.js +0 -2
  40. package/dist/mcp/tools/account.js +50 -42
  41. package/dist/mcp/tools/answers.js +8 -4
  42. package/dist/mcp/tools/wallet.js +205 -148
  43. package/dist/mcp/wallet-api.js +78 -0
  44. package/dist/schema/card.js +6 -10
  45. package/package.json +2 -1
@@ -8,7 +8,7 @@ import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./reco
8
8
  import { rotationGap } from "./rotation.js";
9
9
  import { membershipsUsed } from "./setup.js";
10
10
  import { optionName } from "./rule-text.js";
11
- import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
11
+ import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, capSentence, } from "./text.js";
12
12
  import { cardValuation } from "./valuation.js";
13
13
  import { EPSILON } from "./compare.js";
14
14
  export const ALL_PERSONAL_CARDS = {
@@ -117,7 +117,7 @@ function conditionsText(catalog, card, r) {
117
117
  if (r.membership !== null)
118
118
  conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
119
119
  if (r.cap) {
120
- conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
120
+ conditions.push(`${capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency))}.`);
121
121
  }
122
122
  return conditions;
123
123
  }
@@ -223,6 +223,10 @@ export function exploreCards(catalog, query, options, resolved) {
223
223
  ...membershipNeeded(catalog, card, members),
224
224
  ],
225
225
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
226
+ needsMembership: card.requires_membership !== undefined &&
227
+ !members.has(card.requires_membership)
228
+ ? card.requires_membership
229
+ : null,
226
230
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
227
231
  });
228
232
  }
@@ -303,7 +307,7 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
303
307
  function exploreWarnings(catalog, merchant) {
304
308
  const warnings = [];
305
309
  if (merchant.source === 'none') {
306
- warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. Try a category instead.`);
310
+ warnings.push(`earnrate doesn't know "${merchant.input}" yet, so only base rates apply. Try a category, or the name of a larger chain.`);
307
311
  }
308
312
  if (merchant.ambiguity) {
309
313
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
@@ -1,8 +1,4 @@
1
- export function addDays(date, days) {
2
- const t = new Date(`${date}T00:00:00Z`);
3
- t.setUTCDate(t.getUTCDate() + days);
4
- return t.toISOString().slice(0, 10);
5
- }
1
+ import { addDays } from "./periods.js";
6
2
  // Never assumed: a card without a date is ranked at its standard rates.
7
3
  export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
8
4
  // The opening day is day 1, so 365 days run through the day before the first
@@ -1,14 +1,10 @@
1
- import { money, words } from "./text.js";
1
+ import { listed, money, withArticle, words } from "./text.js";
2
2
  export function membership(catalog, id) {
3
3
  return catalog.memberships.find((m) => m.id === id);
4
4
  }
5
5
  export function membershipName(catalog, id) {
6
6
  return membership(catalog, id)?.name ?? words(id);
7
7
  }
8
- // "a Costco membership", for a sentence.
9
- function withArticle(name) {
10
- return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
11
- }
12
8
  // Tiers of one membership: holding one, the others in its group aren't
13
9
  // missing (BofA Preferred Rewards).
14
10
  export function sameGroup(catalog, a, b) {
@@ -54,7 +50,7 @@ export function membershipCosts(catalog, cards, paid) {
54
50
  export function membershipText(catalog, id) {
55
51
  const m = membership(catalog, id);
56
52
  const name = withArticle(m?.name ?? words(id));
57
- return m?.annual_fee === undefined
53
+ return m?.annual_fee === undefined || m.annual_fee === 0
58
54
  ? name
59
55
  : `${name} (${money(m.annual_fee)} a year)`;
60
56
  }
@@ -67,5 +63,5 @@ export function membershipNeeded(catalog, card, paid) {
67
63
  }
68
64
  export function membershipNote(catalog, m) {
69
65
  const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
70
- return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
66
+ return `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
71
67
  }
@@ -2,17 +2,15 @@ import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
4
  import { inEffect } from "./periods.js";
5
- import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
5
+ import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
6
6
  import { cardValuation } from "./valuation.js";
7
7
  function ineligibleText(catalog, wallet, notWith) {
8
8
  const held = wallet.cards
9
9
  .filter((c) => notWith.includes(c.id))
10
- .map((c) => c.checkout_label ??
11
- catalog.cards.find((k) => k.id === c.id)?.name ??
12
- c.id);
10
+ .map((c) => heldName(catalog, c));
13
11
  return held.length === 0
14
12
  ? null
15
- : `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
13
+ : `Likely not eligible: you hold ${listed(held)}, and the issuer's terms rule out its cardholders.`;
16
14
  }
17
15
  export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
18
16
  // "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
@@ -23,6 +23,11 @@ export function periodKey(period, date, anniversary) {
23
23
  export function daysBetween(from, to) {
24
24
  return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
25
25
  }
26
+ export function addDays(date, days) {
27
+ const t = new Date(`${date}T00:00:00Z`);
28
+ t.setUTCDate(t.getUTCDate() + days);
29
+ return t.toISOString().slice(0, 10);
30
+ }
26
31
  // The user's local calendar date, not UTC: a purchase at 9pm in Los Angeles
27
32
  // belongs to that day.
28
33
  export function localToday(now = new Date()) {
@@ -1,3 +1,4 @@
1
+ import { listed } from "./text.js";
1
2
  export const VALUATION_PROFILES = {
2
3
  flat: { MR: 1.0, UR: 1.0 },
3
4
  'points-heavy': { MR: 1.7, UR: 1.7 },
@@ -7,7 +8,7 @@ export function profileText(profile) {
7
8
  const values = Object.entries(VALUATION_PROFILES[profile]);
8
9
  const cents = [...new Set(values.map(([, v]) => v))];
9
10
  return cents.length === 1
10
- ? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
11
+ ? `${listed(values.map(([id]) => id))} at ${String(cents[0])}¢`
11
12
  : values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
12
13
  }
13
14
  export function isValuationProfile(name) {
@@ -9,7 +9,7 @@ import { creditAt, creditDollars } from "./credits.js";
9
9
  import { ruleWhat } from "./rule-text.js";
10
10
  import { rotationGap } from "./rotation.js";
11
11
  import { EPSILON, isClose } from "./compare.js";
12
- import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
12
+ import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
13
13
  import { cardValuation } from "./valuation.js";
14
14
  export const STALE_AFTER_DAYS = 180;
15
15
  // The engine only tells the U.S. from everywhere else.
@@ -17,7 +17,7 @@ export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: e
17
17
  export const isForeign = (purchase) => purchase.country !== 'US';
18
18
  // A fee that isn't on file counts as the usual 3%, so a card can't win
19
19
  // abroad on a guess that it charges none.
20
- export const ASSUMED_FOREIGN_FEE = 3;
20
+ const ASSUMED_FOREIGN_FEE = 3;
21
21
  export const foreignFeeFor = (card, purchase) => isForeign(purchase)
22
22
  ? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
23
23
  : 0;
@@ -149,7 +149,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
149
149
  const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
150
150
  if (best.rule) {
151
151
  const note = best.rule.note ? ` ${best.rule.note}` : '';
152
- lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
152
+ lines.push(`${standard} on ${ruleWhat(catalog, best.rule, card)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
153
153
  }
154
154
  else {
155
155
  lines.push(`${standard}, its base rate. No bonus category applies here.`);
@@ -169,10 +169,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
169
169
  const partners = card.pools_into?.when_wallet_has ?? [];
170
170
  const heldPartners = wallet.cards
171
171
  .filter((h) => partners.includes(h.id))
172
- .map((h) => h.checkout_label ??
173
- catalog.cards.find((c) => c.id === h.id)?.name ??
174
- h.id);
175
- lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
172
+ .map((h) => heldName(catalog, h));
173
+ lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${listed(heldPartners)}.`);
176
174
  }
177
175
  if (currency !== 'cash') {
178
176
  lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
@@ -196,17 +194,17 @@ function warningsFor(catalog, entry, purchase) {
196
194
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
197
195
  }
198
196
  else if (purchase.amount !== null && purchase.amount > remaining) {
199
- warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
197
+ warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)}: only ${money(remaining)} is left for ${period}.`);
200
198
  }
201
199
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
202
- warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
200
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)} for ${period}.`);
203
201
  }
204
202
  }
205
203
  if (nominal.rule &&
206
204
  nominal.cap &&
207
205
  nominal !== best &&
208
206
  nominal.cap.remaining === 0) {
209
- 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.`);
207
+ warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule, card)} 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.`);
210
208
  }
211
209
  if (best.rule?.unverified !== undefined) {
212
210
  warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
@@ -275,7 +273,7 @@ function tieNote(entries) {
275
273
  const first = tied[0];
276
274
  if (!first)
277
275
  return null;
278
- const names = tied.map(displayName).join(' and ');
276
+ const names = listed(tied.map(displayName));
279
277
  const verb = tied.length === 1 ? 'earns' : 'earn';
280
278
  const why = winner.upside - first.upside > EPSILON
281
279
  ? 'because its points can be worth more'
@@ -285,11 +283,11 @@ function tieNote(entries) {
285
283
  : 'by a fixed order; either works';
286
284
  return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
287
285
  }
286
+ // "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
287
+ const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
288
288
  // Finds the closest point inside a currency's value range where the winner
289
289
  // stops winning, varying one currency at a time. Returns are linear in the
290
290
  // cents-per-point value, so each pair of cards flips at most once.
291
- // "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
292
- const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
293
291
  function sensitivity(catalog, entries) {
294
292
  const [winner, ...rest] = entries;
295
293
  if (!winner)
@@ -338,7 +336,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
338
336
  const rule = e.evaluation.best.rule;
339
337
  return rule &&
340
338
  merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
341
- ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
339
+ ? [`${displayName(e)} (${ruleWhat(catalog, rule, e.card)})`]
342
340
  : [];
343
341
  });
344
342
  assumptions.push(naming.length === 0
@@ -488,9 +486,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
488
486
  const ties = !isBest && gap <= EPSILON;
489
487
  const bonus = open && (open.behind || ties) ? open : null;
490
488
  const best = winner?.effectiveReturnPct ?? 0;
491
- if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
492
- notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} 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.`);
493
- }
489
+ const toGo = open
490
+ ? `${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}`
491
+ : '';
492
+ if (open && isBest && !bonus)
493
+ notes.push(`${displayName(open.entry)} is the best card here anyway, and this counts toward its welcome bonus (${toGo}).`);
494
+ else if (open && !bonus && isClose(open.entry.effectiveReturnPct, best))
495
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${toGo}). You're on pace, so this uses the higher rate.`);
494
496
  const chosen = bonus?.entry ?? winner;
495
497
  // A tie among the top cards doesn't matter when a welcome bonus decides.
496
498
  const tie = bonus ? null : tieNote(entries);
@@ -80,7 +80,7 @@ export function applyBonusBooking(wallet, b) {
80
80
  }
81
81
  // Every purchase on a card counts toward each of its spend milestones. None
82
82
  // for a cardmember-year milestone on a card with no anniversary.
83
- export function milestoneBookings(catalog, wallet, purchase) {
83
+ function milestoneBookings(catalog, wallet, purchase) {
84
84
  const held = wallet.cards.find((c) => c.id === purchase.cardId);
85
85
  const card = catalog.cards.find((c) => c.id === purchase.cardId);
86
86
  if (!held || !card)
@@ -1,5 +1,5 @@
1
1
  import { findMerchant } from "../merchants/resolve.js";
2
- import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, words, } from "./text.js";
2
+ import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, words, } from "./text.js";
3
3
  // What a rule or a pick covers, in words, for answers and wallet lists.
4
4
  // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
5
5
  // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
@@ -22,51 +22,99 @@ export function optionName(catalog, card, choiceId, option) {
22
22
  parts.push('online shopping');
23
23
  return parts.length > 0 ? listed(parts) : words(option);
24
24
  }
25
- // What a rule covers, as a list of phrases.
26
- export function ruleParts(catalog, rule) {
27
- if (rule.match.any)
25
+ // What a rule covers, as a list of phrases. With the card, a pick reads
26
+ // "the 2 categories you pick each quarter" and a rule for every purchase reads
27
+ // "every purchase" when it's the card's only one.
28
+ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
29
+ const m = rule.match;
30
+ if (m.any)
28
31
  return [
29
32
  rule.geography === 'foreign_only'
30
33
  ? 'purchases outside the U.S.'
31
34
  : rule.requires?.payment !== undefined
32
35
  ? 'purchases'
33
- : 'everything else',
36
+ : card?.rules.every((r) => r.match.any)
37
+ ? 'every purchase'
38
+ : 'everything else',
34
39
  ];
35
- if (rule.match.choice !== undefined)
36
- return [`your ${choiceName(rule.match.choice)} picks`];
37
- const merchants = rule.match.merchants ?? [];
38
- // A long list of mostly one kind of store reads as "select streaming
39
- // services": Amex's streaming list also names Audible, which isn't one.
40
+ if (m.choice !== undefined) {
41
+ const choice = card?.choices.find((c) => c.id === m.choice);
42
+ if (!choice)
43
+ return [`your ${choiceName(m.choice)} picks`];
44
+ const which = choice.pick === 1
45
+ ? 'the category'
46
+ : `the ${String(choice.pick)} categories`;
47
+ return [
48
+ `${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
49
+ ];
50
+ }
51
+ const merchants = m.merchants ?? [];
52
+ const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
53
+ const merchantParts = (everyStore ? null : shortenedMerchants(catalog, merchants)) ??
54
+ merchants.map((id) => merchantName(catalog, id));
55
+ // A rule naming both matches a purchase that is both.
56
+ if (merchants.length > 0 && categories.length > 0)
57
+ return [`${listed(merchantParts)} when it codes as ${listed(categories)}`];
58
+ const parts = [...categories, ...(merchants.length > 0 ? merchantParts : [])];
59
+ if (parts.length > 0)
60
+ return parts;
61
+ return m.mccs?.length
62
+ ? [`purchases coded MCC ${m.mccs.map(String).join(', ')}`]
63
+ : [words(rule.id)];
64
+ }
65
+ // A long list of mostly one kind of store reads as "select streaming
66
+ // services": Amex's streaming list also names Audible, which isn't one.
67
+ // Null when the list is read out in full.
68
+ function shortenedKind(catalog, merchants) {
40
69
  const counts = new Map();
41
- for (const m of merchants) {
42
- const category = findMerchant(catalog, m)?.category;
70
+ for (const id of merchants) {
71
+ const category = findMerchant(catalog, id)?.category;
43
72
  if (category)
44
73
  counts.set(category, (counts.get(category) ?? 0) + 1);
45
74
  }
46
75
  const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
47
- const merchantParts = merchants.length > 4 &&
76
+ return merchants.length > 4 &&
48
77
  kind !== undefined &&
49
78
  count >= merchants.length * 0.75
50
- ? [`select ${categoryPhrase(catalog, kind)}`]
51
- : merchants.map((m) => merchantName(catalog, m));
52
- const parts = [
53
- ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
54
- ...merchantParts,
55
- ];
56
- return parts.length > 0 ? parts : [words(rule.id)];
79
+ ? kind
80
+ : null;
57
81
  }
58
- export function ruleWhat(catalog, rule) {
82
+ export function shortenedMerchants(catalog, merchants) {
83
+ const kind = shortenedKind(catalog, merchants);
84
+ return kind === null ? null : [`select ${categoryPhrase(catalog, kind)}`];
85
+ }
86
+ export function ruleWhat(catalog, rule, card, options = {}) {
59
87
  const payment = rule.requires?.payment;
60
- const what = listed(ruleParts(catalog, rule));
88
+ const what = listed(ruleParts(catalog, rule, card, options));
61
89
  return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
62
90
  }
91
+ // "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
92
+ // category's own preposition, "at" for a store unless its data says "on" or
93
+ // it's a booking through a travel site ("on hotels through Amex Travel"), and
94
+ // a shortened list ("select streaming services") reads like its kind.
95
+ export function rulePreposition(catalog, rules) {
96
+ const readsOn = (r) => {
97
+ if (r.match.any === true || r.match.choice !== undefined)
98
+ return true;
99
+ const merchants = r.match.merchants ?? [];
100
+ const kind = shortenedKind(catalog, merchants);
101
+ return ((r.match.categories ?? []).every((c) => categoryReadsOn(catalog, c)) &&
102
+ (kind !== null
103
+ ? categoryReadsOn(catalog, kind)
104
+ : merchants.every((id) => {
105
+ const merchant = findMerchant(catalog, id);
106
+ return (merchant?.preposition === 'on' || merchant?.kind === 'booking');
107
+ })));
108
+ };
109
+ return rules.length > 0 && rules.every(readsOn) ? 'on' : 'at';
110
+ }
63
111
  // "double every rate for the first year", "an extra 3% cash back on your 3%
64
112
  // picks for the first year".
65
113
  export function introWhat(catalog, card, intro) {
66
114
  const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
67
115
  const on = intro.rules === undefined
68
116
  ? 'every rate'
69
- : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
117
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r, card)))].join('; ');
70
118
  const change = intro.multiply === undefined
71
119
  ? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
72
120
  : intro.multiply === 2
@@ -77,6 +77,10 @@ export function cardInfo(catalog, card, wallet, today) {
77
77
  network: card.network,
78
78
  currency: card.currency,
79
79
  annualFee: card.annual_fee ?? null,
80
+ firstYearFee: card.first_year_fee !== undefined &&
81
+ card.first_year_fee !== card.annual_fee
82
+ ? card.first_year_fee
83
+ : null,
80
84
  foreignTransactionFee: card.foreign_transaction_fee,
81
85
  business: card.business,
82
86
  onlyAt: card.only_at ?? null,
@@ -48,6 +48,11 @@ export function feeText(fees) {
48
48
  return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
49
49
  return `${money(annualFee)} annual fee`;
50
50
  }
51
+ export function heldName(catalog, held) {
52
+ return (held.checkout_label ??
53
+ catalog.cards.find((c) => c.id === held.id)?.name ??
54
+ held.id);
55
+ }
51
56
  export function currencyName(catalog, id) {
52
57
  return catalog.currencies.find((c) => c.id === id)?.name ?? id;
53
58
  }
@@ -129,6 +134,18 @@ export function paymentNote(cards) {
129
134
  }
130
135
  // A name with its own "and" ("drugstores and pharmacies") would run into the
131
136
  // list's: those lists use commas only, with those names last.
137
+ // "Up to $1,500 of spending a quarter, then 1% cash back"
138
+ export const capSentence = (cap, then, of = 'spending', aside = '') => `Up to ${wholeDollars(cap.amount)} of ${of} ${CAP_PERIODS[cap.period]}${aside}, then ${then}`;
139
+ // "a Costco membership", "an Instacart credit", "an IHG card", "a Uber credit":
140
+ // by sound, so a leading "you" or "won" takes "a" and a letter said with a
141
+ // vowel sound ("an REI") takes "an".
142
+ export function withArticle(name) {
143
+ const initialism = /^[A-Z][A-Z&.-]{1,5}(?![a-z])/.test(name);
144
+ const an = initialism
145
+ ? /^[AEFHILMNORSX]/.test(name)
146
+ : /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
147
+ return `${an ? 'an' : 'a'} ${name}`;
148
+ }
132
149
  // "Amex Platinum's", "Capital One Savor Rewards'".
133
150
  export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
134
151
  export const listed = (parts, joiner = 'and') => {
@@ -168,7 +185,7 @@ export const CAP_ADJECTIVES = {
168
185
  calendar_year: 'calendar-year',
169
186
  cardmember_year: 'cardmember-year',
170
187
  };
171
- // "the $15,000 calendar-year spend", for a spend milestone.
188
+ // "the $15,000 calendar-year spend", for a spend bonus.
172
189
  export const MILESTONE_PERIODS = {
173
190
  calendar_year: 'calendar-year',
174
191
  cardmember_year: 'cardmember-year',
@@ -190,7 +207,7 @@ export const introRateText = (earn, standard, days, until) => `${earn} in your $
190
207
  // For a card whose opening date isn't known: it's never assumed to be new.
191
208
  export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
192
209
  export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
193
- // Merchant statement credits. Always dollars, never a percentage.
210
+ // Statement credits. Always dollars, never a percentage.
194
211
  export const CREDIT_PERIODS = {
195
212
  month: 'each month',
196
213
  quarter: 'each quarter',
@@ -207,7 +224,7 @@ export const CREDIT_THIS_PERIOD = {
207
224
  };
208
225
  export const ENROLL_FIRST = 'enroll first';
209
226
  // The group of cards with a credit at the store, shown before the ranking.
210
- export const CREDIT_GROUP = 'With a store credit';
227
+ export const CREDIT_GROUP = 'With a statement credit';
211
228
  // "$75 back each quarter"
212
229
  export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
213
230
  // "$75 back each quarter, then 1x Amex Membership Rewards"
@@ -218,9 +235,9 @@ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_P
218
235
  export function creditAnswerText(c, dollars) {
219
236
  const credit = `${c.name} credit (${creditBack(c)})`;
220
237
  if (!c.enrolled)
221
- return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
238
+ return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
222
239
  if (c.left === null)
223
- return `Has a ${credit} here; give the card's anniversary to track it.`;
240
+ return `Has ${withArticle(credit)} here; give the card's anniversary to track it.`;
224
241
  if (c.left <= 0)
225
242
  return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
226
243
  if (dollars === null)
@@ -249,4 +266,4 @@ export const enrollCreditText = (card, names) => names.length === 1
249
266
  ? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
250
267
  : `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
251
268
  // Simulations, the cheat sheet and welcome-offer comparisons.
252
- export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
269
+ export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
@@ -134,15 +134,15 @@ export function cardFields(card, held, input, today) {
134
134
  const milestone = card.milestones.find((m) => m.id === id);
135
135
  if (!milestone) {
136
136
  problems.push(card.milestones.length > 0
137
- ? `${card.id} has no spend milestone '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
138
- : `${card.id} has no spend milestones.`);
137
+ ? `${card.id} has no spend bonus '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
138
+ : `${card.id} has no spend bonuses.`);
139
139
  continue;
140
140
  }
141
141
  if (!(spent >= 0))
142
- problems.push('Spending toward a milestone must be $0 or more.');
142
+ problems.push('Spending toward a spend bonus must be $0 or more.');
143
143
  const period = periodKey(milestone.period, today, anniversary);
144
144
  if (period === null) {
145
- problems.push(`The '${id}' milestone resets on the card's anniversary; give the anniversary (MM-DD) too.`);
145
+ problems.push(`The '${id}' spend bonus resets on the card's anniversary; give the anniversary (MM-DD) too.`);
146
146
  continue;
147
147
  }
148
148
  usage[id] = { period, spent };
@@ -169,7 +169,7 @@ export function cardFields(card, held, input, today) {
169
169
  if (!credit) {
170
170
  problems.push(card.credits.length > 0
171
171
  ? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
172
- : `${card.id} has no merchant credits.`);
172
+ : `${card.id} has no statement credits.`);
173
173
  continue;
174
174
  }
175
175
  if (!(used >= 0 && used <= credit.amount))
@@ -4,12 +4,12 @@ import { introUntil, introWindows, openedOn } from "./intro.js";
4
4
  import { membershipName } from "./memberships.js";
5
5
  import { milestoneSummaries } from "./milestones.js";
6
6
  import { creditStates } from "./credits.js";
7
- import { creditWhere, optionName, ruleParts } from "./rule-text.js";
7
+ import { creditWhere, optionName, ruleParts, rulePreposition, } from "./rule-text.js";
8
8
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
9
9
  import { STALE_AFTER_DAYS } from "./recommend.js";
10
10
  import { rotationGap } from "./rotation.js";
11
11
  import { activationRules } from "./setup.js";
12
- import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
12
+ import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
13
13
  import { cardValuation, currencyValuation } from "./valuation.js";
14
14
  const matchKeys = (rule) => rule.match.choice !== undefined
15
15
  ? []
@@ -30,11 +30,9 @@ function capLimit(card, shown, rule) {
30
30
  shown.filter((r) => r.cap === rule.cap).length > 1;
31
31
  return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
32
32
  }
33
- // Every bonus rate the cardholder gets now, best first, so none reads as the
34
- // base rate. Rates behind a membership the wallet lacks are left out, and so
35
- // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
36
- // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
37
- // a line.
33
+ // Best first. Leaves out rates behind a membership the wallet lacks and a
34
+ // lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
35
+ // without Prime, once Prime is held). Rules at the same rate share a line.
38
36
  function headlineRates(catalog, wallet, card, held, currency, today) {
39
37
  const shown = card.rules
40
38
  .filter((r) => inEffect(r, today) &&
@@ -65,22 +63,18 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
65
63
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
66
64
  const payment = r.requires?.payment;
67
65
  const key = [
68
- `${earnText(catalog, r.rate, currency)} on`,
66
+ earnText(catalog, r.rate, currency),
69
67
  capLimit(card, shown, r),
70
68
  pending ? ', once activated' : '',
71
69
  payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
72
70
  ].join('|');
73
- const whats = lines.get(key) ?? [];
74
- for (const part of ruleParts(catalog, r))
75
- if (!whats.includes(part))
76
- whats.push(part);
77
- lines.set(key, whats);
71
+ lines.set(key, [...(lines.get(key) ?? []), r]);
78
72
  }
79
73
  return [
80
- ...[...lines].map(([key, whats]) => {
74
+ ...[...lines].map(([key, rules]) => {
81
75
  const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
82
76
  const after = `${limit}${activate}`;
83
- return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
77
+ return `${head} ${wherePhrase(catalog, card, rules)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
84
78
  }),
85
79
  ...(shown.some((r) => r.match.any &&
86
80
  r.geography !== 'foreign_only' &&
@@ -92,6 +86,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
92
86
  }
93
87
  // Bonuses current or starting soon that the card hasn't activated, one per
94
88
  // activation group (one activation covers a quarter's categories).
89
+ // "at gas stations and restaurants, and on transit": each rule's parts after
90
+ // its own preposition, the "at" ones first.
91
+ export function wherePhrase(catalog, card, rules) {
92
+ const parts = { at: [], on: [] };
93
+ for (const r of rules) {
94
+ const list = parts[rulePreposition(catalog, [r])];
95
+ for (const part of ruleParts(catalog, r, card))
96
+ if (!list.includes(part))
97
+ list.push(part);
98
+ }
99
+ const { at, on } = parts;
100
+ return [
101
+ ...(at.length > 0 ? [`at ${listed(at)}`] : []),
102
+ ...(on.length > 0 ? [`on ${listed(on)}`] : []),
103
+ ].join(at.length > 1 ? ', and ' : ' and ');
104
+ }
95
105
  export function activationsDue(catalog, card, held, today) {
96
106
  const seen = new Set();
97
107
  const out = [];
@@ -103,13 +113,11 @@ export function activationsDue(catalog, card, held, today) {
103
113
  seen.add(g);
104
114
  const rules = card.rules.filter((r) => group.includes(r.id));
105
115
  const first = rules[0];
106
- const categories = rules.flatMap((r) => r.match.categories ?? []);
107
- const servicesOnly = categories.length > 0 &&
108
- rules.every((r) => (r.match.merchants ?? []).length === 0) &&
109
- categories.every((c) => categoryReadsOn(catalog, c));
110
116
  out.push({
111
- what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
112
- preposition: servicesOnly ? 'on' : 'at',
117
+ what: listed([
118
+ ...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
119
+ ]),
120
+ where: wherePhrase(catalog, card, rules),
113
121
  from: first?.valid_from ?? null,
114
122
  to: first?.valid_to ?? null,
115
123
  activateBy: first?.activate_by ?? null,
@@ -154,8 +162,9 @@ function capSummaries(catalog, card, held, today) {
154
162
  const shared = card.caps.find((c) => c.id === key);
155
163
  const label = shared?.rotating
156
164
  ? 'the rotating bonus categories'
157
- : listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
158
- words(key);
165
+ : listed([
166
+ ...new Set(covered.flatMap((r) => ruleParts(catalog, r, card))),
167
+ ]) || words(key);
159
168
  return {
160
169
  key,
161
170
  label,