earnrate 0.2.15 → 0.2.17

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 (54) hide show
  1. package/README.md +14 -8
  2. package/data/CHANGELOG.md +14 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-platinum.yaml +15 -1
  5. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  6. package/data/cards/chase-freedom-flex.yaml +7 -7
  7. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  8. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +5 -1
  10. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  11. package/data/cards/chase-ink-business-cash.yaml +5 -1
  12. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  13. package/data/cards/chase-prime-visa.yaml +3 -3
  14. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  15. package/data/cards/chase-united-business.yaml +5 -1
  16. package/data/cards/chase-united-club-business.yaml +5 -1
  17. package/data/cards/chase-united-club.yaml +5 -1
  18. package/data/cards/chase-united-explorer.yaml +5 -1
  19. package/data/cards/chase-united-quest.yaml +5 -1
  20. package/data/cards/citi-strata-premier.yaml +14 -1
  21. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  22. package/data/merchants/overrides.yaml +3 -3
  23. package/dist/cli/args.js +36 -1
  24. package/dist/cli/run.js +14 -1
  25. package/dist/cli/wallet.js +18 -8
  26. package/dist/data/integrity.js +13 -0
  27. package/dist/data/wallet-edit.js +5 -0
  28. package/dist/engine/cheat-sheet.js +1 -1
  29. package/dist/engine/checklist.js +26 -6
  30. package/dist/engine/credits.js +56 -7
  31. package/dist/engine/evaluate.js +21 -1
  32. package/dist/engine/explore.js +32 -10
  33. package/dist/engine/memberships.js +22 -10
  34. package/dist/engine/milestones.js +11 -3
  35. package/dist/engine/offers.js +9 -4
  36. package/dist/engine/recommend.js +21 -2
  37. package/dist/engine/rule-text.js +20 -7
  38. package/dist/engine/setup.js +7 -6
  39. package/dist/engine/simulate.js +43 -10
  40. package/dist/engine/text.js +46 -5
  41. package/dist/engine/wallet-change.js +18 -7
  42. package/dist/engine/wallet-summary.js +45 -24
  43. package/dist/format/card-text.js +6 -12
  44. package/dist/format/explore.js +12 -8
  45. package/dist/format/offers.js +1 -1
  46. package/dist/format/wallet.js +2 -0
  47. package/dist/mcp/inputs.js +17 -1
  48. package/dist/mcp/schemas.js +24 -0
  49. package/dist/mcp/tools/account.js +50 -42
  50. package/dist/mcp/tools/wallet.js +207 -150
  51. package/dist/mcp/wallet-api.js +78 -0
  52. package/dist/schema/card.js +11 -0
  53. package/dist/schema/wallet.js +6 -2
  54. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
- import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
3
+ import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { introWindows, openedOn, withIntro } from "./intro.js";
5
5
  import { membershipName } from "./memberships.js";
6
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
@@ -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, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
12
+ import { amountUnknownText, CAP_ADJECTIVES, categoryPhrase, codesAs, creditAnswerText, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, possessive, roundCents, shortDate, wholeDollars, } 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.
@@ -99,6 +99,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
99
99
  }
100
100
  : null;
101
101
  const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
102
+ const larger = amountUpgrade(card, held, ctx, evaluation.best.rate);
102
103
  const credit = options.credits === false
103
104
  ? null
104
105
  : creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
@@ -133,6 +134,15 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
133
134
  creditPct,
134
135
  }
135
136
  : null,
137
+ ifLarger: larger
138
+ ? {
139
+ amount: larger.amount,
140
+ rate: larger.nominalRate,
141
+ returnPct: (perDollar - evaluation.best.rate + larger.rate) *
142
+ valuation.centsPerUnit -
143
+ fee,
144
+ }
145
+ : null,
136
146
  });
137
147
  }
138
148
  entries.sort(compareEntries);
@@ -375,6 +385,15 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
375
385
  const currency = e.valuation.currency;
376
386
  assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
377
387
  }
388
+ for (const e of entries) {
389
+ if (!e.ifLarger || !winner)
390
+ continue;
391
+ if (e !== winner &&
392
+ e.ifLarger.returnPct <= winner.effectiveReturnPct + EPSILON)
393
+ continue;
394
+ const currency = e.valuation.currency;
395
+ assumptions.push(amountUnknownText(displayName(e), e.ifLarger.amount, earnText(catalog, e.ifLarger.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
396
+ }
378
397
  for (const e of entries) {
379
398
  if (!e.ifNew || !winner)
380
399
  continue;
@@ -1,5 +1,5 @@
1
1
  import { findMerchant } from "../merchants/resolve.js";
2
- import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, words, } from "./text.js";
2
+ import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, wholeDollars, 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.
@@ -31,11 +31,13 @@ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
31
31
  return [
32
32
  rule.geography === 'foreign_only'
33
33
  ? 'purchases outside the U.S.'
34
- : rule.requires?.payment !== undefined
35
- ? 'purchases'
36
- : card?.rules.every((r) => r.match.any)
37
- ? 'every purchase'
38
- : 'everything else',
34
+ : rule.min_amount !== undefined
35
+ ? `purchases of ${wholeDollars(rule.min_amount)} or more`
36
+ : rule.requires?.payment !== undefined
37
+ ? 'purchases'
38
+ : card?.rules.every((r) => r.match.any)
39
+ ? 'every purchase'
40
+ : 'everything else',
39
41
  ];
40
42
  if (m.choice !== undefined) {
41
43
  const choice = card?.choices.find((c) => c.id === m.choice);
@@ -85,7 +87,10 @@ export function shortenedMerchants(catalog, merchants) {
85
87
  }
86
88
  export function ruleWhat(catalog, rule, card, options = {}) {
87
89
  const payment = rule.requires?.payment;
88
- const what = listed(ruleParts(catalog, rule, card, options));
90
+ const parts = listed(ruleParts(catalog, rule, card, options));
91
+ const what = rule.min_amount === undefined || rule.match.any
92
+ ? parts
93
+ : `${parts}, on purchases of ${wholeDollars(rule.min_amount)} or more`;
89
94
  return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
90
95
  }
91
96
  // "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
@@ -141,6 +146,14 @@ export function creditName(catalog, credit) {
141
146
  ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
142
147
  ]);
143
148
  }
149
+ // A short name for a list of credits: the first store a credit names
150
+ // ("Grubhub" for the dining credit at five chains); creditName has them all.
151
+ export function creditLabel(catalog, credit) {
152
+ const [first, ...more] = credit.match.merchants ?? [];
153
+ return first === undefined || more.length === 0
154
+ ? creditName(catalog, credit)
155
+ : merchantName(catalog, first);
156
+ }
144
157
  // "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
145
158
  // the category's or merchant's own preposition.
146
159
  export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
@@ -1,10 +1,10 @@
1
1
  import { isActivated, pickedCategories } from "./evaluate.js";
2
- import { membership, membershipName, sameGroup } from "./memberships.js";
3
- import { milestoneWhat } from "./milestones.js";
2
+ import { effectiveMemberships, membership, membershipName, sameGroup, } from "./memberships.js";
3
+ import { milestoneStarts, 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, possessive } from "./text.js";
7
+ import { creditBack, creditTerms, 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
@@ -108,7 +108,7 @@ export function cardInfo(catalog, card, wallet, today) {
108
108
  period: m.period,
109
109
  points: m.points ?? null,
110
110
  rewards: m.rewards ?? [],
111
- text: milestoneWhat(m).replace(/^the /, ''),
111
+ text: `${milestoneWhat(m).replace(/^the /, '')}${milestoneStarts(m, today)}`,
112
112
  })),
113
113
  introRates: card.intro_rates.map((i) => ({
114
114
  id: i.id,
@@ -123,7 +123,7 @@ export function cardInfo(catalog, card, wallet, today) {
123
123
  amount: c.amount,
124
124
  period: c.period,
125
125
  enroll: c.enroll === true,
126
- text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
126
+ text: `${creditBack(c)} ${creditWhere(catalog, c)}${creditTerms(c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
127
127
  };
128
128
  }),
129
129
  };
@@ -177,7 +177,8 @@ export function setupNotes(catalog, wallet, held, today) {
177
177
  });
178
178
  }
179
179
  // With one tier of a group saved, the others aren't missing.
180
- const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
180
+ const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
181
+ const missing = membershipsUsed(card).filter((m) => !has.some((held) => held === m || sameGroup(catalog, held, m)));
181
182
  if (missing.length > 0) {
182
183
  notes.push({
183
184
  kind: 'membership',
@@ -1,12 +1,13 @@
1
1
  import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
2
+ import { allCreditsYear, usedCreditsYear } from "./credits.js";
2
3
  import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
3
4
  import { idealHolding, matchesFilters, } from "./explore.js";
4
5
  import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
5
6
  import { choicePeriod, periodKey } from "./periods.js";
6
7
  import { applyBooking, capBooking } from "./record.js";
7
- import { milestoneWhat } from "./milestones.js";
8
+ import { liveMilestones, milestoneStarts, milestoneWhat } from "./milestones.js";
8
9
  import { rotationGap } from "./rotation.js";
9
- import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
10
+ import { creditsCountedText, money, paymentNote, roundCents, shortCardName, wholeDollars, possessive, } from "./text.js";
10
11
  import { cardValuation } from "./valuation.js";
11
12
  import { EPSILON, HALF_CENT } from "./compare.js";
12
13
  const MONTHS = 12;
@@ -127,9 +128,11 @@ held = []) {
127
128
  // cardmember-year total is the same twelve months' spending.
128
129
  const milestones = [];
129
130
  const milestoneNotes = [];
131
+ const lastMonth = monthDates(start).at(-1) ?? start;
130
132
  for (const { card, cents } of cards) {
131
133
  const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
132
- for (const m of card.milestones) {
134
+ // One that starts during the year counts, as it will the year after.
135
+ for (const m of liveMilestones(card, lastMonth)) {
133
136
  if (spent + EPSILON < m.spend)
134
137
  continue;
135
138
  const value = roundCents(((m.points ?? 0) * cents) / 100);
@@ -139,12 +142,25 @@ held = []) {
139
142
  points: m.points ?? null,
140
143
  value,
141
144
  });
142
- milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
145
+ milestoneNotes.push(`${shortCardName(card.name)}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${milestoneStarts(m, start)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
143
146
  }
144
147
  }
145
148
  const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
146
149
  milestones.reduce((sum, m) => sum + m.value, 0));
147
150
  const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
151
+ const credits = cards.flatMap(({ card }) => {
152
+ const holding = held.includes(card.id);
153
+ const raw = wallet.cards.find((h) => h.id === card.id);
154
+ const year = holding && raw
155
+ ? usedCreditsYear(catalog, card, raw, start)
156
+ : allCreditsYear(catalog, card, start);
157
+ return year.creditIds.length === 0 ? [] : [{ ...year, held: holding }];
158
+ });
159
+ const creditSum = (heldOnes) => roundCents(credits
160
+ .filter((c) => c.held === heldOnes)
161
+ .reduce((sum, c) => sum + c.yearly, 0));
162
+ const heldCredits = creditSum(true);
163
+ const otherCredits = creditSum(false);
148
164
  const paid = paidMemberships(catalog, {
149
165
  memberships: wallet.memberships,
150
166
  heldCards: held,
@@ -155,7 +171,12 @@ held = []) {
155
171
  const notes = [...milestoneNotes];
156
172
  for (const m of memberships)
157
173
  notes.push(membershipNote(catalog, m));
158
- const paying = paymentNote(cards.map(({ card }) => ({ name: card.name, card })));
174
+ for (const c of credits.filter((c) => c.held)) {
175
+ const card = cards.find((x) => x.card.id === c.cardId)?.card;
176
+ if (card)
177
+ notes.push(creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, c));
178
+ }
179
+ const paying = paymentNote(cards.map(({ card }) => ({ name: shortCardName(card.name), card })));
159
180
  if (paying !== null)
160
181
  notes.push(paying);
161
182
  const dates = monthDates(start);
@@ -164,13 +185,13 @@ held = []) {
164
185
  ...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
165
186
  ];
166
187
  if (gaps.length > 0)
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.`);
188
+ notes.push(`${possessive(shortCardName(card.name))} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
168
189
  if (card.annual_fee === undefined)
169
- notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
190
+ notes.push(`${possessive(shortCardName(card.name))} annual fee isn't on file, so none is subtracted.`);
170
191
  else if (!held.includes(card.id) &&
171
192
  card.first_year_fee !== undefined &&
172
193
  card.first_year_fee !== card.annual_fee)
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.`);
194
+ notes.push(`${possessive(shortCardName(card.name))} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
174
195
  }
175
196
  return {
176
197
  cards: wallet.cards.map((h) => ({
@@ -182,7 +203,11 @@ held = []) {
182
203
  annualFees,
183
204
  memberships,
184
205
  membershipFees,
185
- net: roundCents(rewards - annualFees - membershipFees),
206
+ heldCredits,
207
+ net: roundCents(rewards + heldCredits - annualFees - membershipFees),
208
+ otherCredits,
209
+ netIfCreditsUsed: roundCents(rewards + heldCredits + otherCredits - annualFees - membershipFees),
210
+ credits,
186
211
  lines: lineResults,
187
212
  milestones,
188
213
  notes,
@@ -317,6 +342,10 @@ function projectedWallet(wallet, catalog, start) {
317
342
  ...(held.anniversary !== undefined
318
343
  ? { anniversary: held.anniversary }
319
344
  : {}),
345
+ ...(held.enrolled !== undefined ? { enrolled: held.enrolled } : {}),
346
+ ...(held.skipped_credits !== undefined
347
+ ? { skipped_credits: held.skipped_credits }
348
+ : {}),
320
349
  };
321
350
  }),
322
351
  };
@@ -339,13 +368,17 @@ export function simulateAdditions(catalog, wallet, lines, options) {
339
368
  const extraRewards = roundCents(simulation.rewards - current.rewards);
340
369
  const annualFee = card.annual_fee ?? 0;
341
370
  const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
371
+ const gain = roundCents(extraRewards - annualFee - membershipFee);
372
+ const credits = allCreditsYear(catalog, card, options.start).yearly;
342
373
  additions.push({
343
374
  cardId: card.id,
344
375
  picks: simulation.cards.at(-1)?.picks ?? {},
345
376
  extraRewards,
346
377
  annualFee,
347
378
  membershipFee,
348
- gain: roundCents(extraRewards - annualFee - membershipFee),
379
+ gain,
380
+ credits,
381
+ gainIfCreditsUsed: roundCents(gain + credits),
349
382
  simulation,
350
383
  });
351
384
  }
@@ -87,7 +87,7 @@ export function codesAs(catalog, merchant) {
87
87
  const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
88
88
  return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
89
89
  }
90
- // "Values points at: Membership Rewards (MR) at 1¢." for the currencies an
90
+ // "Point values: Membership Rewards (MR) at 1¢." for the currencies an
91
91
  // answer ranks, or null when every card shown earns cash.
92
92
  export function pointValuesText(catalog, shown) {
93
93
  const values = new Map();
@@ -97,7 +97,7 @@ export function pointValuesText(catalog, shown) {
97
97
  if (values.size === 0)
98
98
  return null;
99
99
  const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
100
- return `Values points at: ${list.join(', ')}.`;
100
+ return `Point values: ${listed(list)}.`;
101
101
  }
102
102
  export const NETWORK_NAMES = {
103
103
  visa: 'Visa',
@@ -115,6 +115,7 @@ export const PAYMENT_PHRASES = {
115
115
  };
116
116
  export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
117
117
  export const paymentUnknownText = (name, payment, earn, standard) => `${capitalize(PAYMENT_PHRASES[payment])}, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Say how you're paying to count it.`;
118
+ export const amountUnknownText = (name, amount, earn, standard) => `On a purchase of ${wholeDollars(amount)} or more, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Give the amount to count it.`;
118
119
  // For answers that never ask how each purchase is paid (cheat sheets and
119
120
  // simulations): which cards' rates that need a way of paying are left out.
120
121
  export function paymentNote(cards) {
@@ -146,6 +147,14 @@ export function withArticle(name) {
146
147
  : /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
147
148
  return `${an ? 'an' : 'a'} ${name}`;
148
149
  }
150
+ // A card's name as people say it: "Amex Blue Cash Preferred", not "American
151
+ // Express Blue Cash Preferred Card".
152
+ export const shortCardName = (name) => name
153
+ .replace(/^American Express /, 'Amex ')
154
+ .replace(/^The (.+) Card from American Express$/, 'Amex $1')
155
+ // "Card" is part of Apple Card's name, not a suffix.
156
+ .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
157
+ .replace(/ Card by /, ' by ');
149
158
  // "Amex Platinum's", "Capital One Savor Rewards'".
150
159
  export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
151
160
  export const listed = (parts, joiner = 'and') => {
@@ -225,14 +234,21 @@ export const CREDIT_THIS_PERIOD = {
225
234
  export const ENROLL_FIRST = 'enroll first';
226
235
  // The group of cards with a credit at the store, shown before the ranking.
227
236
  export const CREDIT_GROUP = 'With a statement credit';
237
+ // A credit the cardholder said they don't use, in list_wallet.
238
+ export const CREDIT_SKIPPED = "marked as one you don't use, so it isn't counted";
228
239
  // "$75 back each quarter"
229
240
  export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
241
+ const minPurchase = (amount) => `a purchase of ${wholeDollars(amount)} or more`;
242
+ // " on a purchase of $500 or more", " through Sep 30, 2027": the limits a
243
+ // credit's issuer sets beyond its amount and period.
244
+ export const creditTerms = (c) => `${c.min_amount === undefined ? '' : ` with ${minPurchase(c.min_amount)}`}${c.valid_to === undefined ? '' : ` through ${shortDate(c.valid_to)}`}`;
230
245
  // "$75 back each quarter, then 1x Amex Membership Rewards"
231
- export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
246
+ export const creditThen = (c, earn) => `${creditBack(c)}${creditTerms(c)}, then ${earn}`;
232
247
  // "$35 left this quarter"
233
248
  export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
234
249
  // A line for an answer that uses the card's credit, or says why it doesn't.
235
250
  export function creditAnswerText(c, dollars) {
251
+ const min = c.minAmount ?? null;
236
252
  const credit = `${c.name} credit (${creditBack(c)})`;
237
253
  if (!c.enrolled)
238
254
  return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
@@ -241,7 +257,9 @@ export function creditAnswerText(c, dollars) {
241
257
  if (c.left <= 0)
242
258
  return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
243
259
  if (dollars === null)
244
- return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
260
+ return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it${min === null ? '' : ` (it needs ${minPurchase(min)})`}.`;
261
+ if (dollars <= 0 && min !== null)
262
+ return `Its ${credit} needs ${minPurchase(min)}, so it doesn't count here.`;
245
263
  return dollars < c.left
246
264
  ? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
247
265
  : `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
@@ -265,5 +283,28 @@ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c
265
283
  export const enrollCreditText = (card, names) => names.length === 1
266
284
  ? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
267
285
  : `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
268
- // Simulations, the cheat sheet and welcome-offer comparisons.
286
+ // Credits that need no enrolling, before the cardholder says whether they
287
+ // use them: they don't offset the annual fee until then.
288
+ export const askCreditText = (card, names) => names.length === 1
289
+ ? `Do you use ${possessive(card)} ${names[0] ?? ''} credit? Mark it used and it counts against the annual fee.`
290
+ : `Do you use ${possessive(card)} ${listed(names)} credits? Mark them used and they count against the annual fee.`;
291
+ // The cheat sheet: its picks are by rate alone.
269
292
  export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
293
+ // "$424 a year: Dunkin', Uber, Grubhub and Resy"
294
+ export const creditsYearText = (c) => `${wholeDollars(c.yearly)} a year: ${listed(c.names)}`;
295
+ // A held card's credits the cardholder uses, counted against its fee:
296
+ // "Amex Gold's credits you use (Dunkin' and Uber) count against its fee: $204 a year."
297
+ export const creditsCountedText = (card, fee, c) => fee > 0
298
+ ? `${possessive(card)} credits you use (${listed(c.names)}) count against its fee: ${wholeDollars(c.yearly)} a year.`
299
+ : `${possessive(card)} credits you use (${listed(c.names)}) add ${wholeDollars(c.yearly)} a year.`;
300
+ // Rankings of cards the person doesn't hold: the honest figure leaves
301
+ // credits out, and the one with them is shown beside it.
302
+ export const CREDITS_APART = "Statement credits (dollars back at named stores) aren't in the ranking: they're worth it only if you'd spend at those places anyway. Where a card has them, what it nets if you'd use them all is shown apart.";
303
+ export const HELD_CREDITS_COUNTED = "Statement credits on your own cards count against their fees once you've said you use them: marked enrolled, or marked used (or already used) for ones that need no enrolling.";
304
+ export const CREDITS_ONLY_IF = "They're worth that only if you'd spend at those places anyway.";
305
+ // "If you'd use all the statement credits on Amex Gold and Sapphire Reserve
306
+ // ($724 a year), the pair nets $X a year."
307
+ export const pairCreditsUsedText = (cards, yearly, dollars) => `If you'd use all the statement credits on ${listed(cards)} (${wholeDollars(yearly)} a year), the pair nets ${wholeDollars(dollars)} a year.`;
308
+ // "If you'd use all its statement credits ($424 a year: …), it nets $X a
309
+ // year." `adds` for a card beside the ones already held.
310
+ export const ifCreditsUsedText = (c, dollars, verb = 'nets', owner = 'its') => `If you'd use all ${owner} statement credits (${creditsYearText(c)}), it ${verb} ${wholeDollars(dollars)} a year.`;
@@ -150,17 +150,25 @@ export function cardFields(card, held, input, today) {
150
150
  fields.milestone_usage = usage;
151
151
  }
152
152
  if (input.enrolled !== undefined) {
153
- const needing = card.credits
154
- .filter((c) => c.enroll === true)
155
- .map((c) => c.id);
153
+ const ids = card.credits.map((c) => c.id);
156
154
  for (const id of input.enrolled) {
157
- if (!needing.includes(id))
158
- problems.push(needing.length > 0
159
- ? `'${id}' isn't a ${card.id} credit that needs enrolling. Those are: ${idList(needing)}.`
160
- : `${card.id} has no credits that need enrolling.`);
155
+ if (!ids.includes(id))
156
+ problems.push(ids.length > 0
157
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
158
+ : `${card.id} has no statement credits.`);
161
159
  }
162
160
  fields.enrolled = [...new Set(input.enrolled)];
163
161
  }
162
+ if (input.skippedCredits !== undefined) {
163
+ const ids = card.credits.map((c) => c.id);
164
+ for (const id of input.skippedCredits) {
165
+ if (!ids.includes(id))
166
+ problems.push(ids.length > 0
167
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
168
+ : `${card.id} has no statement credits.`);
169
+ }
170
+ fields.skipped_credits = [...new Set(input.skippedCredits)];
171
+ }
164
172
  if (input.creditUsed !== undefined) {
165
173
  const usage = { ...held?.credit_usage };
166
174
  const anniversary = fields.anniversary ?? held?.anniversary ?? null;
@@ -251,6 +259,9 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
251
259
  : { milestone_usage: f.milestone_usage }),
252
260
  ...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
253
261
  ...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
262
+ ...(f.skipped_credits === undefined
263
+ ? {}
264
+ : { skipped_credits: f.skipped_credits }),
254
265
  ...(f.bonus ? { bonus: f.bonus } : {}),
255
266
  });
256
267
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -1,15 +1,15 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
3
  import { introUntil, introWindows, openedOn } from "./intro.js";
4
- import { membershipName } from "./memberships.js";
4
+ import { effectiveMemberships, membershipName } from "./memberships.js";
5
5
  import { milestoneSummaries } from "./milestones.js";
6
- import { creditStates } from "./credits.js";
6
+ import { creditStates, usedCreditsYear } from "./credits.js";
7
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, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
12
+ import { CAP_PERIODS, creditBack, creditTerms, creditsCountedText, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, CREDIT_SKIPPED, earnText, listed, PAYMENT_PHRASES, shortCardName, 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
  ? []
@@ -34,11 +34,12 @@ function capLimit(card, shown, rule) {
34
34
  // lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
35
35
  // without Prime, once Prime is held). Rules at the same rate share a line.
36
36
  function headlineRates(catalog, wallet, card, held, currency, today) {
37
+ const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
37
38
  const shown = card.rules
38
39
  .filter((r) => inEffect(r, today) &&
39
40
  r.rate > card.base_rate &&
40
41
  (r.requires?.membership === undefined ||
41
- wallet.memberships.includes(r.requires.membership)))
42
+ has.includes(r.requires.membership)))
42
43
  .sort((a, b) => b.rate - a.rate);
43
44
  const lines = new Map();
44
45
  for (const r of shown) {
@@ -68,23 +69,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
68
69
  pending ? ', once activated' : '',
69
70
  payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
70
71
  ].join('|');
71
- const line = lines.get(key) ?? { at: [], on: [] };
72
- const whats = line[rulePreposition(catalog, [r])];
73
- for (const part of ruleParts(catalog, r, card))
74
- if (!whats.includes(part))
75
- whats.push(part);
76
- lines.set(key, line);
72
+ lines.set(key, [...(lines.get(key) ?? []), r]);
77
73
  }
78
74
  return [
79
- ...[...lines].map(([key, { at, on }]) => {
75
+ ...[...lines].map(([key, rules]) => {
80
76
  const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
81
77
  const after = `${limit}${activate}`;
82
- // "at gas stations and on transit" when one line mixes the two.
83
- const where = [
84
- ...(at.length > 0 ? [`at ${listed(at)}`] : []),
85
- ...(on.length > 0 ? [`on ${listed(on)}`] : []),
86
- ].join(at.length > 1 ? ', and ' : ' and ');
87
- return `${head} ${where}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
78
+ return `${head} ${wherePhrase(catalog, card, rules)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
88
79
  }),
89
80
  ...(shown.some((r) => r.match.any &&
90
81
  r.geography !== 'foreign_only' &&
@@ -96,6 +87,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
96
87
  }
97
88
  // Bonuses current or starting soon that the card hasn't activated, one per
98
89
  // activation group (one activation covers a quarter's categories).
90
+ // "at gas stations and restaurants, and on transit": each rule's parts after
91
+ // its own preposition, the "at" ones first.
92
+ export function wherePhrase(catalog, card, rules) {
93
+ const parts = { at: [], on: [] };
94
+ for (const r of rules) {
95
+ const list = parts[rulePreposition(catalog, [r])];
96
+ for (const part of ruleParts(catalog, r, card))
97
+ if (!list.includes(part))
98
+ list.push(part);
99
+ }
100
+ const { at, on } = parts;
101
+ return [
102
+ ...(at.length > 0 ? [`at ${listed(at)}`] : []),
103
+ ...(on.length > 0 ? [`on ${listed(on)}`] : []),
104
+ ].join(at.length > 1 ? ', and ' : ' and ');
105
+ }
99
106
  export function activationsDue(catalog, card, held, today) {
100
107
  const seen = new Set();
101
108
  const out = [];
@@ -111,7 +118,7 @@ export function activationsDue(catalog, card, held, today) {
111
118
  what: listed([
112
119
  ...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
113
120
  ]),
114
- preposition: rulePreposition(catalog, rules),
121
+ where: wherePhrase(catalog, card, rules),
115
122
  from: first?.valid_from ?? null,
116
123
  to: first?.valid_to ?? null,
117
124
  activateBy: first?.activate_by ?? null,
@@ -208,6 +215,7 @@ export function walletSummary(catalog, wallet, today) {
208
215
  caps: capSummaries(catalog, card, held, today),
209
216
  milestones: milestoneSummaries(card, held, today),
210
217
  credits: creditSummaries(catalog, card, held, today),
218
+ ...usedCredits(catalog, card, held, today),
211
219
  chosen: held.chosen
212
220
  ? {
213
221
  period: held.chosen.period,
@@ -237,13 +245,24 @@ export function walletSummary(catalog, wallet, today) {
237
245
  cards,
238
246
  };
239
247
  }
248
+ function usedCredits(catalog, card, held, today) {
249
+ const used = usedCreditsYear(catalog, card, held, today);
250
+ return {
251
+ usedCredits: used.yearly,
252
+ usedCreditsText: used.creditIds.length === 0
253
+ ? null
254
+ : creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, used),
255
+ };
256
+ }
240
257
  function creditSummaries(catalog, card, held, today) {
241
258
  return creditStates(catalog, card, held, today).map((s) => {
242
- const status = !s.enrolled
243
- ? `${ENROLL_FIRST}, then mark it enrolled`
244
- : s.left === null
245
- ? "give the card's anniversary to track it"
246
- : `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
259
+ const status = s.skipped
260
+ ? CREDIT_SKIPPED
261
+ : !s.enrolled
262
+ ? `${ENROLL_FIRST}, then mark it enrolled`
263
+ : s.left === null
264
+ ? "give the card's anniversary to track it"
265
+ : `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
247
266
  return {
248
267
  id: s.credit.id,
249
268
  name: s.name,
@@ -251,12 +270,14 @@ function creditSummaries(catalog, card, held, today) {
251
270
  period: s.period,
252
271
  enroll: s.credit.enroll === true,
253
272
  enrolled: s.enrolled,
273
+ skipped: s.skipped,
274
+ confirmed: s.confirmed,
254
275
  currentPeriod: s.key,
255
276
  until: s.until,
256
277
  used: s.used,
257
278
  left: s.left,
258
279
  tracked: held.credit_usage?.[s.credit.id] !== undefined,
259
- text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
280
+ text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}${creditTerms(s.credit)}: ${status}.`,
260
281
  };
261
282
  });
262
283
  }
@@ -1,23 +1,17 @@
1
1
  // Card names, fees and requirements as earnrate.dev and its static search
2
2
  // pages show them. Node-free: the site bundles it.
3
3
  import { membershipName, membershipText } from "../engine/memberships.js";
4
- import { capSentence, earnText, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
4
+ import { capSentence, earnText, PICK_PERIODS, shortCardName, wholeDollars, words, } from "../engine/text.js";
5
5
  // An issuer's name as people know it, from data/issuers.yaml.
6
6
  export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
7
7
  export function cardText(catalog) {
8
8
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
9
- function shortName(id) {
10
- const name = cards.get(id)?.name ?? id;
11
- return (name
12
- .replace(/^American Express /, 'Amex ')
13
- .replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
14
- // "Card" is part of Apple Card's name, not a suffix.
15
- .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
16
- .replace(/ Card by /, ' by '));
17
- }
18
- function feeLabel(id) {
9
+ const shortName = (id) => shortCardName(cards.get(id)?.name ?? id);
10
+ // `membership: false` leaves out a membership the card needs, for lists
11
+ // whose own conditions say it only when it applies.
12
+ function feeLabel(id, { membership = true } = {}) {
19
13
  const card = cards.get(id);
20
- const m = card?.requires_membership;
14
+ const m = membership ? card?.requires_membership : undefined;
21
15
  const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
22
16
  const fee = card?.annual_fee;
23
17
  if (fee === undefined)