earnrate 0.2.13 → 0.2.15

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 (50) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +28 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
  5. package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
  6. package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
  7. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  8. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  9. package/data/cards/discover-it-cash-back.yaml +9 -6
  10. package/data/cards/venmo-credit-card.yaml +1 -1
  11. package/data/categories.yaml +10 -0
  12. package/data/merchants/seed.yaml +3 -1
  13. package/dist/cli/import.js +14 -11
  14. package/dist/cli/todo.js +3 -2
  15. package/dist/cli/wallet.js +50 -17
  16. package/dist/engine/cheat-sheet.js +15 -11
  17. package/dist/engine/checklist.js +23 -25
  18. package/dist/engine/credits.js +2 -2
  19. package/dist/engine/evaluate.js +1 -1
  20. package/dist/engine/explore.js +4 -4
  21. package/dist/engine/intro.js +1 -5
  22. package/dist/engine/memberships.js +2 -6
  23. package/dist/engine/offers.js +3 -5
  24. package/dist/engine/periods.js +5 -0
  25. package/dist/engine/profiles.js +2 -1
  26. package/dist/engine/recommend.js +46 -33
  27. package/dist/engine/record.js +2 -1
  28. package/dist/engine/rotation.js +2 -1
  29. package/dist/engine/rule-text.js +98 -33
  30. package/dist/engine/setup.js +7 -3
  31. package/dist/engine/simulate.js +4 -4
  32. package/dist/engine/text.js +29 -8
  33. package/dist/engine/wallet-change.js +5 -5
  34. package/dist/engine/wallet-summary.js +24 -21
  35. package/dist/format/card-text.js +2 -3
  36. package/dist/format/cards.js +7 -3
  37. package/dist/format/explore.js +2 -2
  38. package/dist/format/wallet-yaml.js +13 -17
  39. package/dist/format/wallet.js +14 -10
  40. package/dist/import/analyze.js +13 -1
  41. package/dist/mcp/context.js +2 -3
  42. package/dist/mcp/inputs.js +3 -3
  43. package/dist/mcp/schemas.js +2 -0
  44. package/dist/mcp/server.js +0 -2
  45. package/dist/mcp/tools/account.js +1 -1
  46. package/dist/mcp/tools/answers.js +10 -5
  47. package/dist/mcp/tools/wallet.js +10 -6
  48. package/dist/schema/card.js +6 -10
  49. package/dist/schema/merchant.js +3 -0
  50. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
- import { openedOn } from "./intro.js";
4
+ import { introWindows, openedOn, withIntro } from "./intro.js";
5
5
  import { membershipName } from "./memberships.js";
6
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
7
7
  import { daysBetween } from "./periods.js";
@@ -9,12 +9,18 @@ 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, } 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.
16
16
  export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
17
17
  export const isForeign = (purchase) => purchase.country !== 'US';
18
+ // A fee that isn't on file counts as the usual 3%, so a card can't win
19
+ // abroad on a guess that it charges none.
20
+ const ASSUMED_FOREIGN_FEE = 3;
21
+ export const foreignFeeFor = (card, purchase) => isForeign(purchase)
22
+ ? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
23
+ : 0;
18
24
  // Any purchase outside the U.S., when no country is named: ZZ is a code ISO
19
25
  // leaves unassigned, and nothing prints it.
20
26
  export const ABROAD = 'ZZ';
@@ -79,8 +85,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
79
85
  const bonusPoints = milestonePoints(milestones);
80
86
  const perDollar = evaluation.best.rate +
81
87
  (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
82
- // An unknown fee isn't guessed: it's warned about instead.
83
- const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
88
+ const fee = foreignFeeFor(card, purchase);
84
89
  const fresh = card.intro_rates.length > 0 && openedOn(held) === null
85
90
  ? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
86
91
  : null;
@@ -138,18 +143,19 @@ function reasoningFor(catalog, wallet, entry, amount) {
138
143
  const { best } = evaluation;
139
144
  const lines = [];
140
145
  const currency = valuation.currency;
146
+ // The standard rate first; a first-year rate on top of it is its own line.
147
+ // The intro's terms stay in search_cards, not in every answer.
148
+ const [intro] = best.intro;
149
+ const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
141
150
  if (best.rule) {
142
151
  const note = best.rule.note ? ` ${best.rule.note}` : '';
143
- 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}`);
152
+ lines.push(`${standard} on ${ruleWhat(catalog, best.rule, card)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
144
153
  }
145
154
  else {
146
- lines.push(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
147
- }
148
- const [intro] = best.intro;
149
- if (intro) {
150
- const note = intro.intro.note ? ` ${intro.intro.note}` : '';
151
- lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
155
+ lines.push(`${standard}, its base rate. No bonus category applies here.`);
152
156
  }
157
+ if (intro)
158
+ lines.push(introRateText(earnText(catalog, best.nominalRate, currency), standard, intro.intro.days, intro.until));
153
159
  const cap = best.cap;
154
160
  if (cap && amount !== null && amount > cap.remaining) {
155
161
  const over = amount - cap.remaining;
@@ -163,10 +169,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
163
169
  const partners = card.pools_into?.when_wallet_has ?? [];
164
170
  const heldPartners = wallet.cards
165
171
  .filter((h) => partners.includes(h.id))
166
- .map((h) => h.checkout_label ??
167
- catalog.cards.find((c) => c.id === h.id)?.name ??
168
- h.id);
169
- 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)}.`);
170
174
  }
171
175
  if (currency !== 'cash') {
172
176
  lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
@@ -177,10 +181,10 @@ function warningsFor(catalog, entry, purchase) {
177
181
  const { card, valuation, evaluation } = entry;
178
182
  const { best, nominal } = evaluation;
179
183
  const warnings = [];
180
- if (entry.fee > 0)
181
- warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
182
184
  if (isForeign(purchase) && card.foreign_transaction_fee === null)
183
- warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
185
+ warnings.push(`Its foreign transaction fee isn't on file, so earnrate assumes the usual ${pct(entry.fee)} and it nets ${pct(entry.rateReturnPct)}. Check the issuer's terms before using it abroad.`);
186
+ else if (entry.fee > 0)
187
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
184
188
  const capLabel = (e) => e.cap
185
189
  ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
186
190
  : '';
@@ -190,17 +194,17 @@ function warningsFor(catalog, entry, purchase) {
190
194
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
191
195
  }
192
196
  else if (purchase.amount !== null && purchase.amount > remaining) {
193
- 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}.`);
194
198
  }
195
199
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
196
- 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}.`);
197
201
  }
198
202
  }
199
203
  if (nominal.rule &&
200
204
  nominal.cap &&
201
205
  nominal !== best &&
202
206
  nominal.cap.remaining === 0) {
203
- 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.`);
204
208
  }
205
209
  if (best.rule?.unverified !== undefined) {
206
210
  warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
@@ -212,12 +216,14 @@ function warningsFor(catalog, entry, purchase) {
212
216
  if (evaluation.staleChoice !== null) {
213
217
  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.`);
214
218
  }
219
+ const windows = introWindows(card, entry.held, purchase.date);
215
220
  for (const rule of evaluation.needsActivation) {
221
+ const rate = earnText(catalog, withIntro(rule.rate, rule.id, windows), valuation.currency);
216
222
  if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
217
- warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
223
+ warnings.push(`The bonus here (${rate}) needed activating by ${shortDate(rule.activate_by)}.`);
218
224
  continue;
219
225
  }
220
- 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)}`}.`);
226
+ warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${rate} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
221
227
  }
222
228
  return warnings;
223
229
  }
@@ -267,7 +273,7 @@ function tieNote(entries) {
267
273
  const first = tied[0];
268
274
  if (!first)
269
275
  return null;
270
- const names = tied.map(displayName).join(' and ');
276
+ const names = listed(tied.map(displayName));
271
277
  const verb = tied.length === 1 ? 'earns' : 'earn';
272
278
  const why = winner.upside - first.upside > EPSILON
273
279
  ? 'because its points can be worth more'
@@ -277,11 +283,11 @@ function tieNote(entries) {
277
283
  : 'by a fixed order; either works';
278
284
  return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
279
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`;
280
288
  // Finds the closest point inside a currency's value range where the winner
281
289
  // stops winning, varying one currency at a time. Returns are linear in the
282
290
  // cents-per-point value, so each pair of cards flips at most once.
283
- // "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
284
- const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
285
291
  function sensitivity(catalog, entries) {
286
292
  const [winner, ...rest] = entries;
287
293
  if (!winner)
@@ -330,7 +336,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
330
336
  const rule = e.evaluation.best.rule;
331
337
  return rule &&
332
338
  merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
333
- ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
339
+ ? [`${displayName(e)} (${ruleWhat(catalog, rule, e.card)})`]
334
340
  : [];
335
341
  });
336
342
  assumptions.push(naming.length === 0
@@ -389,9 +395,12 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
389
395
  const winner = entries[0];
390
396
  const warnings = [];
391
397
  const notes = [];
392
- const charging = entries.filter((e) => e.fee > 0);
398
+ const charging = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee !== null);
399
+ const assumed = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee === null);
393
400
  if (charging.length > 0 && entries.length > 1)
394
401
  notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
402
+ if (assumed.length > 0 && entries.length > 1)
403
+ notes.push(`${listed(assumed.map(displayName))} ${assumed.length === 1 ? 'has' : 'have'} no foreign transaction fee on file, so the usual ${pct(ASSUMED_FOREIGN_FEE)} is subtracted.`);
395
404
  if (merchant.source === 'none') {
396
405
  warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
397
406
  }
@@ -453,7 +462,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
453
462
  const gap = card ? rotationGap(card, purchase.date) : null;
454
463
  if (card && gap !== null) {
455
464
  const name = held.checkout_label ?? card.name;
456
- warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
465
+ warnings.push(`${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
457
466
  }
458
467
  }
459
468
  for (const id of merchant.bookedVia) {
@@ -477,9 +486,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
477
486
  const ties = !isBest && gap <= EPSILON;
478
487
  const bonus = open && (open.behind || ties) ? open : null;
479
488
  const best = winner?.effectiveReturnPct ?? 0;
480
- if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
481
- 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.`);
482
- }
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.`);
483
496
  const chosen = bonus?.entry ?? winner;
484
497
  // A tie among the top cards doesn't matter when a welcome bonus decides.
485
498
  const tie = bonus ? null : tieNote(entries);
@@ -516,7 +529,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
516
529
  const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
517
530
  notes.unshift(ties
518
531
  ? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
519
- : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
532
+ : `You're behind pace on ${possessive(displayName(bonus.entry))} welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
520
533
  ? ''
521
534
  : costHere === null
522
535
  ? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
@@ -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)
@@ -96,6 +96,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
96
96
  spend: p.milestone.spend,
97
97
  before: p.spent,
98
98
  after: roundCents(Math.max(0, p.spent + purchase.amount)),
99
+ started: !p.tracked,
99
100
  },
100
101
  ]);
101
102
  }
@@ -1,4 +1,5 @@
1
1
  import { periodKey } from "./periods.js";
2
+ import { possessive } from "./text.js";
2
3
  // The period of the card's rotating categories that `date` falls in, when no
3
4
  // rule in the data covers it yet; null when it's covered or the card doesn't
4
5
  // rotate.
@@ -16,4 +17,4 @@ export function rotationGap(card, date) {
16
17
  }
17
18
  // What answers say about a gap: "Freedom Flex's rotating categories for
18
19
  // 2027-Q2 aren't in earnrate's data yet. ..."
19
- export const rotationGapText = (name, gap) => `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
20
+ export const rotationGapText = (name, gap) => `${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
@@ -1,15 +1,14 @@
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.
6
- const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
6
+ const BOOKING = /^(Activity|Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
7
+ const plural = (kind) => kind === 'Activity' ? 'activities' : `${kind.toLowerCase()}s`;
7
8
  function merchantName(catalog, id) {
8
9
  const merchant = findMerchant(catalog, id);
9
10
  const name = merchant?.name ?? words(id);
10
- return merchant?.kind === 'booking'
11
- ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
12
- : name;
11
+ return merchant?.kind === 'booking' ? name.replace(BOOKING, plural) : name;
13
12
  }
14
13
  export function optionName(catalog, card, choiceId, option) {
15
14
  const o = card.choices.find((c) => c.id === choiceId)?.options[option];
@@ -23,51 +22,99 @@ export function optionName(catalog, card, choiceId, option) {
23
22
  parts.push('online shopping');
24
23
  return parts.length > 0 ? listed(parts) : words(option);
25
24
  }
26
- // What a rule covers, as a list of phrases.
27
- export function ruleParts(catalog, rule) {
28
- 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)
29
31
  return [
30
32
  rule.geography === 'foreign_only'
31
33
  ? 'purchases outside the U.S.'
32
34
  : rule.requires?.payment !== undefined
33
35
  ? 'purchases'
34
- : 'everything else',
36
+ : card?.rules.every((r) => r.match.any)
37
+ ? 'every purchase'
38
+ : 'everything else',
35
39
  ];
36
- if (rule.match.choice !== undefined)
37
- return [`your ${choiceName(rule.match.choice)} picks`];
38
- const merchants = rule.match.merchants ?? [];
39
- // A long list of mostly one kind of store reads as "select streaming
40
- // 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) {
41
69
  const counts = new Map();
42
- for (const m of merchants) {
43
- const category = findMerchant(catalog, m)?.category;
70
+ for (const id of merchants) {
71
+ const category = findMerchant(catalog, id)?.category;
44
72
  if (category)
45
73
  counts.set(category, (counts.get(category) ?? 0) + 1);
46
74
  }
47
75
  const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
48
- const merchantParts = merchants.length > 4 &&
76
+ return merchants.length > 4 &&
49
77
  kind !== undefined &&
50
78
  count >= merchants.length * 0.75
51
- ? [`select ${categoryPhrase(catalog, kind)}`]
52
- : merchants.map((m) => merchantName(catalog, m));
53
- const parts = [
54
- ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
55
- ...merchantParts,
56
- ];
57
- return parts.length > 0 ? parts : [words(rule.id)];
79
+ ? kind
80
+ : null;
81
+ }
82
+ export function shortenedMerchants(catalog, merchants) {
83
+ const kind = shortenedKind(catalog, merchants);
84
+ return kind === null ? null : [`select ${categoryPhrase(catalog, kind)}`];
58
85
  }
59
- export function ruleWhat(catalog, rule) {
86
+ export function ruleWhat(catalog, rule, card, options = {}) {
60
87
  const payment = rule.requires?.payment;
61
- const what = listed(ruleParts(catalog, rule));
88
+ const what = listed(ruleParts(catalog, rule, card, options));
62
89
  return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
63
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
+ }
64
111
  // "double every rate for the first year", "an extra 3% cash back on your 3%
65
112
  // picks for the first year".
66
113
  export function introWhat(catalog, card, intro) {
67
114
  const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
68
115
  const on = intro.rules === undefined
69
116
  ? 'every rate'
70
- : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
117
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r, card)))].join('; ');
71
118
  const change = intro.multiply === undefined
72
119
  ? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
73
120
  : intro.multiply === 2
@@ -76,9 +123,27 @@ export function introWhat(catalog, card, intro) {
76
123
  return `${change} for the ${introPeriod(intro.days)}`;
77
124
  }
78
125
  // What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
79
- export const creditName = (catalog, credit) => listed([
80
- ...(credit.match.merchants ?? []).map((m) => merchantName(catalog, m)),
81
- ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
82
- ]);
83
- // "at Lululemon", "at restaurants", "on travel": a category's own preposition.
84
- export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ? 'on' : 'at'} ${creditName(catalog, credit)}`;
126
+ // Bookings through a travel site the credit also names go without saying:
127
+ // "Capital One Travel", not each kind of booking through it.
128
+ export function creditName(catalog, credit) {
129
+ const ids = credit.match.merchants ?? [];
130
+ const portals = ids
131
+ .map((m) => findMerchant(catalog, m))
132
+ .filter((m) => m?.kind === 'portal')
133
+ .map((m) => ` through ${m?.name ?? ''}`);
134
+ const merchants = ids.filter((m) => {
135
+ const merchant = findMerchant(catalog, m);
136
+ return !(merchant?.kind === 'booking' &&
137
+ portals.some((p) => merchant.name.endsWith(p)));
138
+ });
139
+ return listed([
140
+ ...merchants.map((m) => merchantName(catalog, m)),
141
+ ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
142
+ ]);
143
+ }
144
+ // "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
145
+ // the category's or merchant's own preposition.
146
+ export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
147
+ (credit.match.merchants ?? []).some((m) => findMerchant(catalog, m)?.preposition === 'on')
148
+ ? 'on'
149
+ : 'at'} ${creditName(catalog, credit)}`;
@@ -4,7 +4,7 @@ import { milestoneWhat } from "./milestones.js";
4
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap, rotationGapText } from "./rotation.js";
6
6
  import { creditName, creditWhere, introWhat } from "./rule-text.js";
7
- import { creditBack, ENROLL_FIRST, idList, listed } from "./text.js";
7
+ import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
8
8
  // Rules that start within this many days are worth activating now.
9
9
  const ACTIVATION_LOOKAHEAD_DAYS = 45;
10
10
  const searchWords = (s) => s
@@ -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,
@@ -168,7 +172,7 @@ export function setupNotes(catalog, wallet, held, today) {
168
172
  notes.push({
169
173
  kind: 'pick',
170
174
  text: choice.default && picked.has(choice.id)
171
- ? `${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}).`
175
+ ? `${possessive(name)} ${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}).`
172
176
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
173
177
  });
174
178
  }
@@ -177,7 +181,7 @@ export function setupNotes(catalog, wallet, held, today) {
177
181
  if (missing.length > 0) {
178
182
  notes.push({
179
183
  kind: 'membership',
180
- text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
184
+ text: `Some of ${possessive(name)} rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
181
185
  });
182
186
  }
183
187
  const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { applyBooking, capBooking } from "./record.js";
7
7
  import { milestoneWhat } from "./milestones.js";
8
8
  import { rotationGap } from "./rotation.js";
9
- import { money, paymentNote, roundCents, wholeDollars } from "./text.js";
9
+ import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
10
10
  import { cardValuation } from "./valuation.js";
11
11
  import { EPSILON, HALF_CENT } from "./compare.js";
12
12
  const MONTHS = 12;
@@ -164,13 +164,13 @@ held = []) {
164
164
  ...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
165
165
  ];
166
166
  if (gaps.length > 0)
167
- notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
167
+ notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
168
168
  if (card.annual_fee === undefined)
169
- notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
169
+ notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
170
170
  else if (!held.includes(card.id) &&
171
171
  card.first_year_fee !== undefined &&
172
172
  card.first_year_fee !== card.annual_fee)
173
- notes.push(`${card.name}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
173
+ notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
174
174
  }
175
175
  return {
176
176
  cards: wallet.cards.map((h) => ({
@@ -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,20 @@ 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
+ }
149
+ // "Amex Platinum's", "Capital One Savor Rewards'".
150
+ export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
132
151
  export const listed = (parts, joiner = 'and') => {
133
152
  const anded = parts.filter((p) => p.includes(' and '));
134
153
  if (anded.length > 0)
@@ -166,7 +185,7 @@ export const CAP_ADJECTIVES = {
166
185
  calendar_year: 'calendar-year',
167
186
  cardmember_year: 'cardmember-year',
168
187
  };
169
- // "the $15,000 calendar-year spend", for a spend milestone.
188
+ // "the $15,000 calendar-year spend", for a spend bonus.
170
189
  export const MILESTONE_PERIODS = {
171
190
  calendar_year: 'calendar-year',
172
191
  cardmember_year: 'cardmember-year',
@@ -187,8 +206,8 @@ export const introSince = (days) => days === 365 ? 'in the last year' : `in the
187
206
  export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
188
207
  // For a card whose opening date isn't known: it's never assumed to be new.
189
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)}.`;
190
- export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
191
- // Merchant statement credits. Always dollars, never a percentage.
209
+ export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
210
+ // Statement credits. Always dollars, never a percentage.
192
211
  export const CREDIT_PERIODS = {
193
212
  month: 'each month',
194
213
  quarter: 'each quarter',
@@ -205,7 +224,7 @@ export const CREDIT_THIS_PERIOD = {
205
224
  };
206
225
  export const ENROLL_FIRST = 'enroll first';
207
226
  // The group of cards with a credit at the store, shown before the ranking.
208
- export const CREDIT_GROUP = 'With a store credit';
227
+ export const CREDIT_GROUP = 'With a statement credit';
209
228
  // "$75 back each quarter"
210
229
  export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
211
230
  // "$75 back each quarter, then 1x Amex Membership Rewards"
@@ -216,9 +235,9 @@ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_P
216
235
  export function creditAnswerText(c, dollars) {
217
236
  const credit = `${c.name} credit (${creditBack(c)})`;
218
237
  if (!c.enrolled)
219
- 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.`;
220
239
  if (c.left === null)
221
- 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.`;
222
241
  if (c.left <= 0)
223
242
  return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
224
243
  if (dollars === null)
@@ -243,6 +262,8 @@ export function creditShort(c, dollars) {
243
262
  // For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
244
263
  // 31, 2026: $35 left."
245
264
  export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
246
- export const enrollCreditText = (card, name) => `Enroll in ${card}'s ${name} credit with the issuer, then mark it enrolled.`;
265
+ export const enrollCreditText = (card, names) => names.length === 1
266
+ ? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
267
+ : `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
247
268
  // Simulations, the cheat sheet and welcome-offer comparisons.
248
- 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))