earnrate 0.2.16 → 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 (52) 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 +13 -7
  24. package/dist/cli/run.js +4 -0
  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/checklist.js +25 -5
  29. package/dist/engine/credits.js +56 -7
  30. package/dist/engine/evaluate.js +21 -1
  31. package/dist/engine/explore.js +27 -9
  32. package/dist/engine/memberships.js +21 -9
  33. package/dist/engine/milestones.js +11 -3
  34. package/dist/engine/offers.js +9 -4
  35. package/dist/engine/recommend.js +21 -2
  36. package/dist/engine/rule-text.js +20 -7
  37. package/dist/engine/setup.js +7 -6
  38. package/dist/engine/simulate.js +43 -10
  39. package/dist/engine/text.js +46 -5
  40. package/dist/engine/wallet-change.js +18 -7
  41. package/dist/engine/wallet-summary.js +25 -10
  42. package/dist/format/card-text.js +2 -10
  43. package/dist/format/explore.js +12 -8
  44. package/dist/format/offers.js +1 -1
  45. package/dist/format/wallet.js +2 -0
  46. package/dist/mcp/inputs.js +9 -1
  47. package/dist/mcp/schemas.js +23 -0
  48. package/dist/mcp/tools/account.js +1 -1
  49. package/dist/mcp/tools/wallet.js +5 -1
  50. package/dist/schema/card.js +11 -0
  51. package/dist/schema/wallet.js +6 -2
  52. package/package.json +1 -1
@@ -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) {
@@ -214,6 +215,7 @@ export function walletSummary(catalog, wallet, today) {
214
215
  caps: capSummaries(catalog, card, held, today),
215
216
  milestones: milestoneSummaries(card, held, today),
216
217
  credits: creditSummaries(catalog, card, held, today),
218
+ ...usedCredits(catalog, card, held, today),
217
219
  chosen: held.chosen
218
220
  ? {
219
221
  period: held.chosen.period,
@@ -243,13 +245,24 @@ export function walletSummary(catalog, wallet, today) {
243
245
  cards,
244
246
  };
245
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
+ }
246
257
  function creditSummaries(catalog, card, held, today) {
247
258
  return creditStates(catalog, card, held, today).map((s) => {
248
- const status = !s.enrolled
249
- ? `${ENROLL_FIRST}, then mark it enrolled`
250
- : s.left === null
251
- ? "give the card's anniversary to track it"
252
- : `${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)}`}`;
253
266
  return {
254
267
  id: s.credit.id,
255
268
  name: s.name,
@@ -257,12 +270,14 @@ function creditSummaries(catalog, card, held, today) {
257
270
  period: s.period,
258
271
  enroll: s.credit.enroll === true,
259
272
  enrolled: s.enrolled,
273
+ skipped: s.skipped,
274
+ confirmed: s.confirmed,
260
275
  currentPeriod: s.key,
261
276
  until: s.until,
262
277
  used: s.used,
263
278
  left: s.left,
264
279
  tracked: held.credit_usage?.[s.credit.id] !== undefined,
265
- text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
280
+ text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}${creditTerms(s.credit)}: ${status}.`,
266
281
  };
267
282
  });
268
283
  }
@@ -1,20 +1,12 @@
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
- }
9
+ const shortName = (id) => shortCardName(cards.get(id)?.name ?? id);
18
10
  // `membership: false` leaves out a membership the card needs, for lists
19
11
  // whose own conditions say it only when it applies.
20
12
  function feeLabel(id, { membership = true } = {}) {
@@ -1,9 +1,13 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_APART, ENROLL_FIRST, HELD_CREDITS_COUNTED, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, listed, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
6
6
  const TOP_BEST = 10;
7
+ // " · $X if you'd use all its credits ($424 a year)"
8
+ const ifCredits = (credits, net, sign = '', its = 'its') => credits > 0
9
+ ? ` · ${sign}${wholeDollars(net)} if you'd use all ${its} credits (${wholeDollars(credits)} a year)`
10
+ : '';
7
11
  export function formatExploration(r, catalog, all) {
8
12
  const m = r.merchant;
9
13
  const title = m.source === 'category'
@@ -83,15 +87,15 @@ export function formatCatalogSimulation(r, catalog, all) {
83
87
  const card = s.cards[0];
84
88
  if (!card)
85
89
  return;
86
- lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
90
+ lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${ifCredits(s.otherCredits, s.netIfCreditsUsed)}${picksText(card.picks)}`);
87
91
  });
88
92
  const pairs = r.pairs.slice(0, top);
89
93
  if (pairs.length > 0) {
90
94
  lines.push('', 'Best two-card pairs:');
91
95
  pairs.forEach((s, i) => {
92
- lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
96
+ lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}${ifCredits(s.otherCredits, s.netIfCreditsUsed, '', 'their')}`);
93
97
  if (s.alternatives.length > 0)
94
- lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
98
+ lines.push(` Same result with ${listed(s.alternatives.map(name), 'or')} as the second card.`);
95
99
  for (const split of splitText(s, name))
96
100
  lines.push(` ${split}`);
97
101
  });
@@ -105,13 +109,13 @@ export function formatCatalogSimulation(r, catalog, all) {
105
109
  ]);
106
110
  if (notes.size > 0)
107
111
  lines.push('', ...notes);
108
- lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
112
+ lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_APART}`);
109
113
  return `${lines.join('\n')}\n`;
110
114
  }
111
115
  export function formatAdditions(r, catalog, all) {
112
116
  const name = cardNamer(catalog);
113
117
  const lines = [
114
- `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
118
+ `Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees${r.current.heldCredits > 0 ? ` and ${wholeDollars(r.current.heldCredits)} of credits you use` : ''}: ${wholeDollars(r.current.net)} after fees` : ''}.`,
115
119
  ];
116
120
  for (const split of splitText(r.current, name))
117
121
  lines.push(` ${split}`);
@@ -122,7 +126,7 @@ export function formatAdditions(r, catalog, all) {
122
126
  else {
123
127
  lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
124
128
  gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
125
- lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
129
+ lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${ifCredits(a.credits, a.gainIfCreditsUsed, '+')}${picksText(a.picks)}`);
126
130
  });
127
131
  }
128
132
  const notes = new Set([
@@ -133,6 +137,6 @@ export function formatAdditions(r, catalog, all) {
133
137
  ]);
134
138
  if (notes.size > 0)
135
139
  lines.push('', ...notes);
136
- lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. ${CREDITS_LEFT_OUT} Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
140
+ lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. ${r.current.heldCredits > 0 ? `${HELD_CREDITS_COUNTED} ` : ''}${CREDITS_APART} Perks like lounge access aren't counted. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
137
141
  return `${lines.join('\n')}\n`;
138
142
  }
@@ -11,7 +11,7 @@ export function formatOffers(c, currencyName, all) {
11
11
  shown.forEach((o, i) => {
12
12
  const weeks = (o.months * 52) / 12;
13
13
  const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
14
- lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
14
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year${o.credits > 0 ? `, ${wholeDollars(o.firstYearValueIfCreditsUsed)} if you'd use all its credits (${wholeDollars(o.credits)} a year)` : ''}`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${capitalize(feeText(o))}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
15
15
  lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
16
16
  if (o.ineligible)
17
17
  lines.push(` ${o.ineligible}`);
@@ -41,6 +41,8 @@ export function formatWalletSummary(summary) {
41
41
  lines.push(` Spend bonus (${m.id}): ${m.text}`);
42
42
  for (const credit of c.credits)
43
43
  lines.push(` Credit (${credit.id}): ${credit.text}`);
44
+ if (c.usedCreditsText !== null)
45
+ lines.push(` ${c.usedCreditsText}`);
44
46
  if (c.anniversary !== null)
45
47
  lines.push(` Account anniversary: ${c.anniversary}.`);
46
48
  if (c.intro?.state === 'active' && c.intro.until !== null)
@@ -53,6 +53,10 @@ const NamedCard = z.object({
53
53
  .array(z.string())
54
54
  .optional()
55
55
  .describe("Statement credits the user has enrolled in with the issuer, as credit ids from search_cards. A credit that needs enrolling counts only when it's here. Ask; never assume."),
56
+ skippedCredits: z
57
+ .array(z.string())
58
+ .optional()
59
+ .describe("Statement credit ids the user says they don't use. They don't count toward a pick or against the card's annual fee."),
56
60
  opened: z
57
61
  .string()
58
62
  .optional()
@@ -92,11 +96,15 @@ const cardFields = {
92
96
  enrolled: z
93
97
  .array(z.string())
94
98
  .optional()
95
- .describe('Statement credit ids the user has enrolled in with the issuer (search_cards and list_wallet list each card\'s credits, e.g. "lululemon" on Amex Platinum). A credit that needs enrolling counts in answers only once it\'s here. Replaces the current list. Ask; never assume.'),
99
+ .describe("Statement credit ids the user has enrolled in with the issuer, or, for a credit that needs no enrolling, says they use (search_cards and list_wallet list each card's credits, e.g. \"lululemon\" on Amex Platinum). A credit that needs enrolling counts in answers only once it's here, and any credit counts against the annual fee only once it's here (or has been used). Replaces the current list. Ask; never assume."),
96
100
  creditUsed: z
97
101
  .record(z.string(), z.number().nonnegative())
98
102
  .optional()
99
103
  .describe('Dollars of a statement credit already received this period: credit id → dollars, e.g. {"lululemon": 40}. Ask; recorded purchases add to it from then on.'),
104
+ skippedCredits: z
105
+ .array(z.string())
106
+ .optional()
107
+ .describe("Statement credit ids the user doesn't use, e.g. [\"dunkin\"] on Amex Gold. They're left out of picks, the to-do list and the annual-fee math; every other credit on the card counts against its fee once it's in enrolled (or, for one that needs no enrolling, has been used). Replaces the current list; [] counts them all again."),
100
108
  bonus: z
101
109
  .object({
102
110
  opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),