earnrate 0.2.17 → 0.2.18

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 (59) hide show
  1. package/README.md +9 -3
  2. package/data/CHANGELOG.md +19 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-platinum.yaml +1 -1
  6. package/data/cards/chase-freedom-flex.yaml +3 -6
  7. package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
  8. package/data/cards/chase-united-business.yaml +3 -0
  9. package/data/cards/chase-united-club-business.yaml +5 -0
  10. package/data/cards/citi-aadvantage-business.yaml +5 -0
  11. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  12. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  13. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  14. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  15. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  16. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  17. package/data/cards/citi-custom-cash.yaml +8 -10
  18. package/data/cards/citi-double-cash.yaml +1 -0
  19. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  20. package/data/cards/citi-strata-elite.yaml +2 -0
  21. package/data/cards/citi-strata-premier.yaml +2 -0
  22. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  23. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  24. package/data/changes.yaml +2840 -0
  25. package/dist/cli/offers.js +21 -5
  26. package/dist/cli/run.js +14 -3
  27. package/dist/cli/wallet.js +28 -3
  28. package/dist/data/change-integrity.js +105 -0
  29. package/dist/data/file-store.js +5 -2
  30. package/dist/data/integrity.js +7 -0
  31. package/dist/data/load.js +5 -0
  32. package/dist/data/store.js +4 -1
  33. package/dist/data/wallet-edit.js +16 -5
  34. package/dist/engine/changes.js +124 -0
  35. package/dist/engine/checklist.js +18 -3
  36. package/dist/engine/explore.js +28 -9
  37. package/dist/engine/offers.js +52 -3
  38. package/dist/engine/periods.js +7 -0
  39. package/dist/engine/renewal.js +101 -0
  40. package/dist/engine/rule-text.js +2 -7
  41. package/dist/engine/setup.js +6 -3
  42. package/dist/engine/simulate.js +14 -7
  43. package/dist/engine/text.js +64 -0
  44. package/dist/engine/wallet-change.js +16 -8
  45. package/dist/engine/wallet-summary.js +4 -0
  46. package/dist/format/card-text.js +9 -2
  47. package/dist/format/offers.js +5 -3
  48. package/dist/format/wallet-yaml.js +3 -0
  49. package/dist/format/wallet.js +6 -1
  50. package/dist/mcp/context.js +11 -3
  51. package/dist/mcp/inputs.js +3 -1
  52. package/dist/mcp/prompts.js +3 -3
  53. package/dist/mcp/schemas.js +24 -0
  54. package/dist/mcp/tools/answers.js +42 -24
  55. package/dist/mcp/tools/wallet.js +28 -8
  56. package/dist/schema/card.js +7 -0
  57. package/dist/schema/changes.js +73 -0
  58. package/dist/schema/wallet.js +5 -1
  59. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
2
  import { allCreditsYear } from "./credits.js";
3
3
  import { matchesFilters } from "./explore.js";
4
+ import { simulateAdditions, typicalSpendLines } from "./simulate.js";
4
5
  import { membershipCosts, paidMemberships } from "./memberships.js";
5
6
  import { inEffect } from "./periods.js";
6
7
  import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
@@ -25,8 +26,27 @@ export function offerTerms(o, currencyName) {
25
26
  : 'with no spending required';
26
27
  return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
27
28
  }
28
- export function compareOffers(catalog, wallet, filters, today) {
29
+ const WEEKS_A_MONTH = 52 / 12;
30
+ function minimumSpend(spend, months, monthly) {
31
+ if (spend <= 0)
32
+ return null;
33
+ const weeks = monthly > 0 ? Math.ceil(spend / (monthly / WEEKS_A_MONTH)) : Infinity;
34
+ return {
35
+ reachable: monthly * months >= spend,
36
+ weeks: Number.isFinite(weeks) ? weeks : 0,
37
+ };
38
+ }
39
+ export function compareOffers(catalog, wallet, filters, today, spending = { lines: typicalSpendLines(catalog), typical: true }) {
29
40
  const held = new Set(wallet.cards.map((c) => c.id));
41
+ const skipped = new Set(wallet.skipped_issuers ?? []);
42
+ const monthly = spending.lines.reduce((sum, l) => sum + l.monthly, 0);
43
+ const { additions } = simulateAdditions(catalog, wallet, spending.lines, {
44
+ filters: { ...filters, issuer: null, includeClosed: true },
45
+ memberships: wallet.memberships,
46
+ valuations: wallet.valuations,
47
+ start: today,
48
+ });
49
+ const added = new Map(additions.map((a) => [a.cardId, a]));
30
50
  const paid = paidMemberships(catalog, {
31
51
  memberships: wallet.memberships,
32
52
  heldCards: [...held],
@@ -38,6 +58,8 @@ export function compareOffers(catalog, wallet, filters, today) {
38
58
  continue;
39
59
  if (!filters.includeHeld && held.has(card.id))
40
60
  continue;
61
+ if (skipped.has(card.issuer))
62
+ continue;
41
63
  if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
42
64
  continue;
43
65
  const offer = card.welcome_offer;
@@ -52,6 +74,25 @@ export function compareOffers(catalog, wallet, filters, today) {
52
74
  const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
53
75
  const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
54
76
  const credits = allCreditsYear(catalog, card, today).yearly;
77
+ const addition = added.get(card.id);
78
+ const minimum = minimumSpend(offer.spend, offer.months, monthly);
79
+ const plan = addition
80
+ ? (() => {
81
+ const reached = minimum === null || minimum.reachable;
82
+ const yearOne = roundCents(addition.extraRewards +
83
+ (reached ? value : 0) -
84
+ (firstYearFee ?? 0) -
85
+ addition.membershipFee);
86
+ const yearTwo = roundCents(addition.extraRewards - addition.annualFee - addition.membershipFee);
87
+ return {
88
+ extraRewards: addition.extraRewards,
89
+ minimumSpend: minimum,
90
+ yearOne,
91
+ yearTwo,
92
+ twoYears: roundCents(yearOne + yearTwo),
93
+ };
94
+ })()
95
+ : null;
55
96
  offers.push({
56
97
  cardId: card.id,
57
98
  cardName: card.name,
@@ -74,19 +115,27 @@ export function compareOffers(catalog, wallet, filters, today) {
74
115
  firstYearValue,
75
116
  credits,
76
117
  firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
118
+ plan,
77
119
  held: held.has(card.id),
78
120
  lastVerified: card.last_verified,
79
121
  verifiedBy: card.verified_by,
80
122
  });
81
123
  }
82
124
  offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
125
+ (b.plan?.twoYears ?? -Infinity) - (a.plan?.twoYears ?? -Infinity) ||
83
126
  b.firstYearValue - a.firstYearValue ||
84
127
  a.cardId.localeCompare(b.cardId));
85
128
  const notes = [
86
129
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
87
130
  `Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
88
- "First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and its perks.",
131
+ `Ranked on the first two years together: what each card ${wallet.cards.length > 0 ? 'adds to your cards' : 'earns'} on ${spending.typical ? 'typical U.S. spending' : 'your spending'}${wallet.cards.length > 0 ? ' (it takes the purchases where it earns more)' : ''}, plus the welcome bonus in year one if the minimum spend fits in the window with all your spending on it, less each year's annual fee and any membership it needs. Year one counts the card at its standard rates.`,
132
+ "First-year value is the bonus alone at your point values, less the first year's fee and membership.",
89
133
  CREDITS_APART,
134
+ ...(skipped.size > 0
135
+ ? [
136
+ `Leaves out cards from ${listed([...skipped].map((i) => catalog.issuers.find((x) => x.id === i)?.name ?? i))}, as you asked.`,
137
+ ]
138
+ : []),
90
139
  ];
91
- return { offers, withoutOffer, notes };
140
+ return { offers, withoutOffer, notes, typical: spending.typical };
92
141
  }
@@ -20,6 +20,13 @@ export function periodKey(period, date, anniversary) {
20
20
  return String(date.slice(5) >= anniversary ? year : year - 1);
21
21
  }
22
22
  }
23
+ // MM-DD that is a real day of the year; 02-29 counts.
24
+ export function isAnniversary(text) {
25
+ if (!/^\d{2}-\d{2}$/.test(text))
26
+ return false;
27
+ const date = new Date(`2024-${text}T00:00:00Z`);
28
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
29
+ }
23
30
  export function daysBetween(from, to) {
24
31
  return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
25
32
  }
@@ -0,0 +1,101 @@
1
+ import { ALL_PERSONAL_CARDS, matchesFilters } from "./explore.js";
2
+ import { usedCreditsYear } from "./credits.js";
3
+ import { daysBetween, isAnniversary } from "./periods.js";
4
+ import { projectedWallet, simulateSingles, simulateWallet, typicalSpendLines, } from "./simulate.js";
5
+ import { roundCents, shortCardName } from "./text.js";
6
+ // Node-free: earnrate.dev runs it in the browser.
7
+ // A fee card shows up this many days before its anniversary, and stays
8
+ // until this many after: the fee posts on the statement after it.
9
+ export const RENEWAL_BEFORE = 30;
10
+ export const RENEWAL_AFTER = 15;
11
+ // The card's anniversary as MM-DD: the saved one, or from the day it opened.
12
+ export function anniversaryOf(held) {
13
+ return (held.anniversary ?? (held.opened ?? held.bonus?.opened)?.slice(5) ?? null);
14
+ }
15
+ const openedOf = (held) => held.opened ?? held.bonus?.opened ?? null;
16
+ // A real date for MM-DD in `year`: Feb 29 falls on Feb 28 in other years.
17
+ function onYear(year, mmdd) {
18
+ const date = `${String(year)}-${mmdd}`;
19
+ if (mmdd !== '02-29')
20
+ return date;
21
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
22
+ return leap ? date : `${String(year)}-02-28`;
23
+ }
24
+ // The renewal date near `today`, from RENEWAL_BEFORE days ahead back to
25
+ // RENEWAL_AFTER days past; null outside that, without an anniversary, or
26
+ // before the account's first year is nearly over.
27
+ export function renewalDue(held, today) {
28
+ const mmdd = anniversaryOf(held);
29
+ if (mmdd === null || !isAnniversary(mmdd))
30
+ return null;
31
+ const opened = openedOf(held);
32
+ const year = Number(today.slice(0, 4));
33
+ for (const y of [year - 1, year, year + 1]) {
34
+ const due = onYear(y, mmdd);
35
+ const days = daysBetween(today, due);
36
+ if (days > RENEWAL_BEFORE || days < -RENEWAL_AFTER)
37
+ continue;
38
+ if (opened !== null && daysBetween(opened, due) < 300)
39
+ continue;
40
+ return due;
41
+ }
42
+ return null;
43
+ }
44
+ // Whether a fee card still pays for itself, for the year after `due`.
45
+ export function renewalCheck(catalog, wallet, card, due, spending = {
46
+ lines: typicalSpendLines(catalog),
47
+ typical: true,
48
+ }) {
49
+ const held = wallet.cards.find((c) => c.id === card.id);
50
+ const projected = projectedWallet(wallet, catalog, due);
51
+ const ids = projected.cards.map((c) => c.id);
52
+ const withCard = simulateWallet(catalog, projected, spending.lines, due, ids);
53
+ const others = projected.cards.filter((c) => c.id !== card.id);
54
+ let without = null;
55
+ let against = null;
56
+ if (others.length > 0) {
57
+ without = simulateWallet(catalog, { ...projected, cards: others }, spending.lines, due, ids.filter((id) => id !== card.id));
58
+ }
59
+ else {
60
+ const noFee = catalog.cards.filter((c) => matchesFilters(c, {
61
+ ...ALL_PERSONAL_CARDS,
62
+ business: card.business,
63
+ noAnnualFee: true,
64
+ }) &&
65
+ c.requires_membership === undefined &&
66
+ c.network !== 'store');
67
+ const [best] = simulateSingles(catalog, noFee, spending.lines, {
68
+ filters: { ...ALL_PERSONAL_CARDS, noAnnualFee: true },
69
+ memberships: wallet.memberships,
70
+ valuations: wallet.valuations,
71
+ start: due,
72
+ });
73
+ if (best) {
74
+ without = best;
75
+ const bestId = best.cards[0]?.cardId;
76
+ const bestCard = catalog.cards.find((c) => c.id === bestId);
77
+ against = bestCard ? shortCardName(bestCard.name) : null;
78
+ }
79
+ }
80
+ // From the saved card: projectedWallet drops credit usage, which is how
81
+ // a credit that needs no enrolling is confirmed in use.
82
+ const credits = held ? usedCreditsYear(catalog, card, held, due).yearly : 0;
83
+ const rewardsAdded = roundCents(withCard.rewards - (without?.rewards ?? 0));
84
+ const fee = card.annual_fee ?? 0;
85
+ const opened = held ? openedOf(held) : null;
86
+ return {
87
+ cardId: card.id,
88
+ due,
89
+ fee,
90
+ firstYearFee: opened !== null &&
91
+ daysBetween(opened, due) < 400 &&
92
+ (card.first_year_fee ?? fee) < fee
93
+ ? (card.first_year_fee ?? 0)
94
+ : null,
95
+ rewardsAdded,
96
+ credits,
97
+ net: roundCents(rewardsAdded + credits - fee),
98
+ against,
99
+ typical: spending.typical,
100
+ };
101
+ }
@@ -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, wholeDollars, words, } from "./text.js";
2
+ import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, choicePhrase, 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.
@@ -43,12 +43,7 @@ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
43
43
  const choice = card?.choices.find((c) => c.id === m.choice);
44
44
  if (!choice)
45
45
  return [`your ${choiceName(m.choice)} picks`];
46
- const which = choice.pick === 1
47
- ? 'the category'
48
- : `the ${String(choice.pick)} categories`;
49
- return [
50
- `${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
51
- ];
46
+ return [choicePhrase(choice)];
52
47
  }
53
48
  const merchants = m.merchants ?? [];
54
49
  const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
@@ -4,7 +4,7 @@ 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, creditTerms, ENROLL_FIRST, idList, listed, possessive, } from "./text.js";
7
+ import { choicePhrase, 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
@@ -89,6 +89,7 @@ export function cardInfo(catalog, card, wallet, today) {
89
89
  id: c.id,
90
90
  pick: c.pick,
91
91
  period: c.period,
92
+ automatic: c.automatic === true,
92
93
  options: Object.keys(c.options),
93
94
  default: c.default ?? null,
94
95
  })),
@@ -172,8 +173,10 @@ export function setupNotes(catalog, wallet, held, today) {
172
173
  notes.push({
173
174
  kind: 'pick',
174
175
  text: choice.default && picked.has(choice.id)
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}).`
176
- : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
176
+ ? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if ${choice.automatic ? 'you expect a different one to have the most spending' : 'a different one was picked'} (${String(choice.pick)} of: ${options}).`
177
+ : choice.automatic
178
+ ? `${name} applies its bonus to ${choicePhrase(choice)}; set its ${choice.id} pick to the one you expect (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`
179
+ : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
177
180
  });
178
181
  }
179
182
  // With one tier of a group saved, the others aren't missing.
@@ -325,15 +325,16 @@ export function simulateCatalog(catalog, lines, options) {
325
325
  return { singles, pairs: foldTies(pairs).slice(0, 10) };
326
326
  }
327
327
  // The person's own wallet, set up as it would run all year: their picks carry
328
- // over, rotating categories get activated, caps start fresh.
329
- function projectedWallet(wallet, catalog, start) {
328
+ // over, rotating categories get activated, caps start fresh. A card earnrate
329
+ // no longer lists is left out, since it can't be simulated.
330
+ export function projectedWallet(wallet, catalog, start) {
330
331
  return {
331
332
  ...wallet,
332
- cards: wallet.cards.map((held) => {
333
+ cards: wallet.cards.flatMap((held) => {
333
334
  const card = catalog.cards.find((c) => c.id === held.id);
334
- const ideal = card
335
- ? idealHolding(card, start, held.chosen?.picks ?? {})
336
- : held;
335
+ if (!card)
336
+ return [];
337
+ const ideal = idealHolding(card, start, held.chosen?.picks ?? {});
337
338
  return {
338
339
  ...ideal,
339
340
  ...(held.checkout_label !== undefined
@@ -346,6 +347,10 @@ function projectedWallet(wallet, catalog, start) {
346
347
  ...(held.skipped_credits !== undefined
347
348
  ? { skipped_credits: held.skipped_credits }
348
349
  : {}),
350
+ // Usage confirms a credit that needs no enrolling is in use.
351
+ ...(held.credit_usage !== undefined
352
+ ? { credit_usage: held.credit_usage }
353
+ : {}),
349
354
  };
350
355
  }),
351
356
  };
@@ -357,7 +362,9 @@ export function simulateAdditions(catalog, wallet, lines, options) {
357
362
  const held = new Set(heldIds);
358
363
  const additions = [];
359
364
  for (const card of catalog.cards) {
360
- if (held.has(card.id) || !matchesFilters(card, options.filters))
365
+ if (held.has(card.id) ||
366
+ !matchesFilters(card, options.filters) ||
367
+ (wallet.skipped_issuers ?? []).includes(card.issuer))
361
368
  continue;
362
369
  const simulation = bestSimulation(catalog, projected.cards, card, lines, {
363
370
  ...options,
@@ -25,6 +25,8 @@ const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateS
25
25
  export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
26
26
  export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
27
27
  export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
28
+ // "Q4 2026", from an ISO date.
29
+ export const quarterYear = (date) => `Q${String(Math.floor((Number(date.slice(5, 7)) - 1) / 3) + 1)} ${date.slice(0, 4)}`;
28
30
  // Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
29
31
  export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
30
32
  // Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
@@ -205,6 +207,36 @@ export const PICK_PERIODS = {
205
207
  calendar_year: 'each year',
206
208
  until_changed: 'until you change it',
207
209
  };
210
+ // "the 2 categories you pick each quarter", or for a card whose issuer
211
+ // applies the bonus itself, "the category you spend the most in each month".
212
+ // `oneOf` reads as one option of several: "one of the 2 categories …".
213
+ export function choicePhrase(c, oneOf = false) {
214
+ const which = c.pick === 1
215
+ ? 'the category'
216
+ : `${oneOf ? 'one of ' : ''}the ${String(c.pick)} categories`;
217
+ const when = c.period === 'until_changed' ? '' : ` ${PICK_PERIODS[c.period]}`;
218
+ return c.automatic
219
+ ? `${which} you spend the most in${when}`
220
+ : `${which} you pick${when}`;
221
+ }
222
+ // What earning a picked rate takes, for an answer's conditions: "Needs
223
+ // dining as its top-category pick (picked each month)", or for an automatic
224
+ // choice, "Counts when dining is the category you spend the most in that
225
+ // month".
226
+ export function pickCondition(c, options, name) {
227
+ if (c.automatic) {
228
+ const period = {
229
+ month: ' that month',
230
+ quarter: ' that quarter',
231
+ calendar_year: ' that year',
232
+ until_changed: '',
233
+ }[c.period];
234
+ return c.pick === 1
235
+ ? `Counts when ${options} is the category you spend the most in${period}`
236
+ : `Counts when ${options} is one of the ${String(c.pick)} categories you spend the most in${period}`;
237
+ }
238
+ return `Needs ${options} as its ${name} pick (${c.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[c.period]})`;
239
+ }
208
240
  // Intro rates (a card's first 365 days): "first year", "first 90 days".
209
241
  export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
210
242
  export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
@@ -308,3 +340,35 @@ export const pairCreditsUsedText = (cards, yearly, dollars) => `If you'd use all
308
340
  // "If you'd use all its statement credits ($424 a year: …), it nets $X a
309
341
  // year." `adds` for a card beside the ones already held.
310
342
  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.`;
343
+ // The renewal check on the to-do list. A card well ahead gets one short
344
+ // line; one that's close or behind gets the parts of the math.
345
+ export function renewalText(name, issuer, r) {
346
+ const fee = r.firstYearFee === null
347
+ ? `${money(r.fee)} fee`
348
+ : `${money(r.fee)} fee, up from ${money(r.firstYearFee)} the first year`;
349
+ const when = `${name} renews around ${shortDate(r.due)} (${fee})`;
350
+ if (r.net >= r.fee)
351
+ return `${when}: it's paying for itself, about ${wholeDollars(r.net)} a year ahead after the fee.`;
352
+ const basis = r.typical ? 'At typical spending' : 'On your spending';
353
+ const over = r.against === null ? 'your other cards would' : `${r.against} would`;
354
+ const earns = Math.round(r.rewardsAdded) > 0
355
+ ? `it earns about ${wholeDollars(r.rewardsAdded)} a year more than ${over}`
356
+ : Math.round(r.rewardsAdded) < 0
357
+ ? `it earns about ${wholeDollars(-r.rewardsAdded)} a year less than ${over}`
358
+ : `it earns about the same as ${over}`;
359
+ const credits = r.credits > 0 ? `, plus ${wholeDollars(r.credits)} in credits you use` : '';
360
+ const result = r.net >= 0
361
+ ? `about ${wholeDollars(r.net)} a year ahead after the fee.`
362
+ : `about ${wholeDollars(-r.net)} a year short of the fee. Check with ${issuer} before the fee posts.`;
363
+ return `${when}. ${basis}, ${earns}${credits}: ${result}`;
364
+ }
365
+ // The next-card planner's figures: "about $1,690 over the first two years
366
+ // ($1,290 the first year, then $394 a year)".
367
+ export const twoYearText = (p) => `about ${wholeDollars(p.twoYears)} over the first two years (${wholeDollars(p.yearOne)} the first year, then ${wholeDollars(p.yearTwo)} a year${p.yearTwo < 0 ? ' after its fee' : ''})`;
368
+ // Whether a welcome offer's minimum spend fits, at the person's spending.
369
+ export function minimumSpendText(spend, months, m, typical) {
370
+ const whose = typical ? 'typical spending' : 'your spending';
371
+ return m.reachable
372
+ ? `The ${money(spend)} minimum takes about ${String(m.weeks)} weeks of all ${whose}.`
373
+ : `The ${money(spend)} minimum in ${String(months)} months is more than ${whose}, so the bonus isn't counted.`;
374
+ }
@@ -2,7 +2,7 @@ import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js"
2
2
  import { WALLET_VERSION, } from "../schema/wallet.js";
3
3
  import { addMonths } from "./bonus.js";
4
4
  import { capByKey } from "./evaluate.js";
5
- import { choicePeriod, periodKey } from "./periods.js";
5
+ import { choicePeriod, isAnniversary, periodKey } from "./periods.js";
6
6
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
7
7
  import { CREDIT_PERIODS, idList, money } from "./text.js";
8
8
  const failed = (error) => ({ ok: false, error });
@@ -15,13 +15,6 @@ function findCatalogCard(catalog, id, today) {
15
15
  ? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
16
16
  : `No card '${id}' in earnrate. Search the catalog to find its id.`;
17
17
  }
18
- function isAnniversary(text) {
19
- const m = /^(\d{2})-(\d{2})$/.exec(text);
20
- if (!m)
21
- return false;
22
- const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
23
- return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
24
- }
25
18
  const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
26
19
  new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
27
20
  function pickKey(card, today) {
@@ -338,6 +331,21 @@ export function withMemberships(catalog, wallet, memberships) {
338
331
  notes: [],
339
332
  };
340
333
  }
334
+ // Issuers to leave out of next-card answers. [] clears the list.
335
+ export function withSkippedIssuers(catalog, wallet, issuers) {
336
+ const known = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
337
+ const unknown = issuers.filter((i) => !known.includes(i));
338
+ if (unknown.length > 0)
339
+ return failed(`No card earnrate knows is from ${unknown.map((i) => `'${i}'`).join(', ')}. Issuers: ${idList(known)}.`);
340
+ const { skipped_issuers: _, ...rest } = wallet;
341
+ const list = [...new Set(issuers)];
342
+ return {
343
+ ok: true,
344
+ wallet: list.length > 0 ? { ...rest, skipped_issuers: list } : rest,
345
+ fields: {},
346
+ notes: [],
347
+ };
348
+ }
341
349
  // Point values in cents per point, by currency id; null goes back to
342
350
  // earnrate's default for that currency.
343
351
  export function withValuations(catalog, wallet, changes) {
@@ -240,6 +240,10 @@ export function walletSummary(catalog, wallet, today) {
240
240
  return {
241
241
  memberships: wallet.memberships,
242
242
  membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
243
+ skippedIssuers: (wallet.skipped_issuers ?? []).map((id) => ({
244
+ id,
245
+ name: catalog.issuers.find((i) => i.id === id)?.name ?? id,
246
+ })),
243
247
  pointValues: pointValues(catalog, wallet),
244
248
  started: null,
245
249
  cards,
@@ -1,7 +1,7 @@
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, shortCardName, wholeDollars, words, } from "../engine/text.js";
4
+ import { capSentence, earnText, PICK_PERIODS, pickCondition, 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) {
@@ -27,7 +27,14 @@ export function cardText(catalog) {
27
27
  function requirementLines(cardId, r, until = null) {
28
28
  const lines = [];
29
29
  if (r.pick)
30
- lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
30
+ lines.push(r.pick.automatic
31
+ ? pickCondition({
32
+ pick: cards
33
+ .get(cardId)
34
+ ?.choices.find((c) => c.id === r.pick?.choice)?.pick ?? 1,
35
+ ...r.pick,
36
+ }, r.pick.options.map(words).join(' or '), '')
37
+ : `Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
31
38
  if (r.activation !== null)
32
39
  lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
33
40
  if (r.membership !== null)
@@ -1,5 +1,5 @@
1
1
  import { offerTerms } from "../engine/offers.js";
2
- import { capitalize, feeText, money, shortDate, wholeDollars, } from "../engine/text.js";
2
+ import { capitalize, feeText, minimumSpendText, money, shortDate, twoYearText, wholeDollars, } from "../engine/text.js";
3
3
  const TOP = 10;
4
4
  export function formatOffers(c, currencyName, all) {
5
5
  if (c.offers.length === 0) {
@@ -11,8 +11,10 @@ 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${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
- lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
14
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} ${o.plan ? twoYearText(o.plan) : `about ${wholeDollars(o.firstYearValue)} the first year`}${o.credits > 0 ? `; its credits are ${wholeDollars(o.credits)} a year more if you'd use them all` : ''}`, ` ${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
+ lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ. The bonus alone is worth about ${wholeDollars(o.firstYearValue)} after the first year's fee.`);
16
+ if (o.plan?.minimumSpend)
17
+ lines.push(` ${minimumSpendText(o.spend, o.months, o.plan.minimumSpend, c.typical)}`);
16
18
  if (o.ineligible)
17
19
  lines.push(` ${o.ineligible}`);
18
20
  if (o.note)
@@ -22,6 +22,9 @@ function compact(wallet) {
22
22
  ...(wallet.memberships.length > 0
23
23
  ? { memberships: wallet.memberships }
24
24
  : {}),
25
+ ...(wallet.skipped_issuers && wallet.skipped_issuers.length > 0
26
+ ? { skipped_issuers: wallet.skipped_issuers }
27
+ : {}),
25
28
  cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
26
29
  };
27
30
  }
@@ -64,7 +64,12 @@ export function formatWalletSummary(summary) {
64
64
  const values = summary.pointValues.length > 0
65
65
  ? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
66
66
  : "Point values: earnrate's defaults.";
67
- return [...cards, memberships, values].join('\n');
67
+ const skipped = summary.skippedIssuers.length > 0
68
+ ? [
69
+ `Next-card answers leave out: ${summary.skippedIssuers.map((i) => `${i.name} (${i.id})`).join(', ')}.`,
70
+ ]
71
+ : [];
72
+ return [...cards, memberships, ...skipped, values].join('\n');
68
73
  }
69
74
  export function formatBooking(amount, name, // the card's short name
70
75
  cap, bonus, milestones = [], credit = null, capLabel = null) {
@@ -80,6 +80,9 @@ export function makeContext(deps) {
80
80
  return {
81
81
  valuations: wallet?.valuations ?? {},
82
82
  memberships: wallet?.memberships ?? [],
83
+ ...(wallet?.skipped_issuers
84
+ ? { skipped_issuers: wallet.skipped_issuers }
85
+ : {}),
83
86
  };
84
87
  }
85
88
  catch {
@@ -95,9 +98,14 @@ export function makeContext(deps) {
95
98
  })),
96
99
  memberships: memberships ?? (await saved()).memberships,
97
100
  }, deps.today());
98
- return typeof named === 'string'
99
- ? named
100
- : { ...named, valuations: (await saved()).valuations };
101
+ if (typeof named === 'string')
102
+ return named;
103
+ const { valuations, skipped_issuers } = await saved();
104
+ return {
105
+ ...named,
106
+ valuations,
107
+ ...(skipped_issuers ? { skipped_issuers } : {}),
108
+ };
101
109
  }
102
110
  const read = await readableWallet();
103
111
  if (typeof read === 'string')
@@ -77,7 +77,7 @@ const cardFields = {
77
77
  .string()
78
78
  .regex(/^\d{2}-\d{2}$/)
79
79
  .optional()
80
- .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
80
+ .describe("The account's anniversary as MM-DD: for cards whose cap resets on it, and for a fee card's renewal check (whether it still pays for itself before the fee comes due). Optional; the opening date stands in. Ask; don't guess."),
81
81
  opened: IsoDate.nullable()
82
82
  .optional()
83
83
  .describe('Optional: the day the account opened, YYYY-MM-DD. Only for cards with a first-year rate (search_cards lists it), which applies only while a purchase falls in the window; without a date the card is ranked at its standard rates. Ask the user; never guess or assume a card is new. null removes it.'),
@@ -171,6 +171,7 @@ export function makeInputs(catalog) {
171
171
  .optional()
172
172
  .describe(`Instead of the user's saved point values (or earnrate's defaults): flat, points at their cash-out value, ${profileText('flat')}; or points-heavy, ${profileText('points-heavy')}, for people who transfer points to airlines and hotels.`),
173
173
  };
174
+ const SkippedIssuers = z.array(z.enum(issuers));
174
175
  const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
175
176
  return {
176
177
  CategoryInput,
@@ -181,6 +182,7 @@ export function makeInputs(catalog) {
181
182
  OfferFilters,
182
183
  CatalogFilters,
183
184
  AllMemberships,
185
+ SkippedIssuers,
184
186
  };
185
187
  }
186
188
  // The catalog filters as exploreCards and simulate take them. Memberships or
@@ -74,9 +74,9 @@ export function registerPrompts(server, deps, ctx) {
74
74
  ]));
75
75
  server.registerPrompt('this-quarter', {
76
76
  title: 'What should I do with my cards this quarter?',
77
- description: 'Bonuses to activate, categories to pick, caps running low and welcome bonuses to finish, from your saved cards.',
77
+ description: 'Bonuses to activate, categories to pick, caps running low, welcome bonuses to finish and fee cards coming up for renewal, from your saved cards.',
78
78
  }, () => ask('What should I do with my cards this quarter?', [
79
- "Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
79
+ "Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first. A renewal check says whether a fee card still pays for itself; give its math as stated, and don't recommend canceling or downgrading on your own.",
80
80
  ctx.walletUse,
81
81
  'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
82
82
  ]));
@@ -92,7 +92,7 @@ export function registerPrompts(server, deps, ctx) {
92
92
  : []),
93
93
  'Find each card with search_cards and check the match with the user, then save it with add_card.',
94
94
  ctx.walletUse,
95
- 'When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships.',
95
+ "When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships. For a card with an annual fee, offer to save its anniversary (or opening date) so earnrate can check before each renewal whether it still pays for itself; it's optional.",
96
96
  'When done, call list_wallet and show its to-do list.',
97
97
  ]));
98
98
  }