earnrate 0.2.16 → 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 (81) hide show
  1. package/README.md +22 -10
  2. package/data/CHANGELOG.md +33 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-business-platinum.yaml +15 -1
  6. package/data/cards/amex-platinum.yaml +1 -1
  7. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  8. package/data/cards/chase-freedom-flex.yaml +9 -12
  9. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  10. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  11. package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
  12. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  13. package/data/cards/chase-ink-business-cash.yaml +5 -1
  14. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  15. package/data/cards/chase-prime-visa.yaml +3 -3
  16. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  17. package/data/cards/chase-united-business.yaml +8 -1
  18. package/data/cards/chase-united-club-business.yaml +10 -1
  19. package/data/cards/chase-united-club.yaml +5 -1
  20. package/data/cards/chase-united-explorer.yaml +5 -1
  21. package/data/cards/chase-united-quest.yaml +5 -1
  22. package/data/cards/citi-aadvantage-business.yaml +5 -0
  23. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  24. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  25. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  26. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  27. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  28. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  29. package/data/cards/citi-custom-cash.yaml +8 -10
  30. package/data/cards/citi-double-cash.yaml +1 -0
  31. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  32. package/data/cards/citi-strata-elite.yaml +2 -0
  33. package/data/cards/citi-strata-premier.yaml +16 -1
  34. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  35. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  36. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  37. package/data/changes.yaml +2840 -0
  38. package/data/merchants/overrides.yaml +3 -3
  39. package/dist/cli/args.js +13 -7
  40. package/dist/cli/offers.js +21 -5
  41. package/dist/cli/run.js +18 -3
  42. package/dist/cli/wallet.js +46 -11
  43. package/dist/data/change-integrity.js +105 -0
  44. package/dist/data/file-store.js +5 -2
  45. package/dist/data/integrity.js +20 -0
  46. package/dist/data/load.js +5 -0
  47. package/dist/data/store.js +4 -1
  48. package/dist/data/wallet-edit.js +21 -5
  49. package/dist/engine/changes.js +124 -0
  50. package/dist/engine/checklist.js +42 -7
  51. package/dist/engine/credits.js +56 -7
  52. package/dist/engine/evaluate.js +21 -1
  53. package/dist/engine/explore.js +52 -15
  54. package/dist/engine/memberships.js +21 -9
  55. package/dist/engine/milestones.js +11 -3
  56. package/dist/engine/offers.js +60 -6
  57. package/dist/engine/periods.js +7 -0
  58. package/dist/engine/recommend.js +21 -2
  59. package/dist/engine/renewal.js +101 -0
  60. package/dist/engine/rule-text.js +21 -13
  61. package/dist/engine/setup.js +12 -8
  62. package/dist/engine/simulate.js +57 -17
  63. package/dist/engine/text.js +110 -5
  64. package/dist/engine/wallet-change.js +34 -15
  65. package/dist/engine/wallet-summary.js +29 -10
  66. package/dist/format/card-text.js +10 -11
  67. package/dist/format/explore.js +12 -8
  68. package/dist/format/offers.js +5 -3
  69. package/dist/format/wallet-yaml.js +3 -0
  70. package/dist/format/wallet.js +8 -1
  71. package/dist/mcp/context.js +11 -3
  72. package/dist/mcp/inputs.js +12 -2
  73. package/dist/mcp/prompts.js +3 -3
  74. package/dist/mcp/schemas.js +47 -0
  75. package/dist/mcp/tools/account.js +1 -1
  76. package/dist/mcp/tools/answers.js +42 -24
  77. package/dist/mcp/tools/wallet.js +33 -9
  78. package/dist/schema/card.js +18 -0
  79. package/dist/schema/changes.js +73 -0
  80. package/dist/schema/wallet.js +11 -3
  81. package/package.json +1 -1
@@ -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.
@@ -87,7 +89,7 @@ export function codesAs(catalog, merchant) {
87
89
  const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
88
90
  return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
89
91
  }
90
- // "Values points at: Membership Rewards (MR) at 1¢." for the currencies an
92
+ // "Point values: Membership Rewards (MR) at 1¢." for the currencies an
91
93
  // answer ranks, or null when every card shown earns cash.
92
94
  export function pointValuesText(catalog, shown) {
93
95
  const values = new Map();
@@ -97,7 +99,7 @@ export function pointValuesText(catalog, shown) {
97
99
  if (values.size === 0)
98
100
  return null;
99
101
  const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
100
- return `Values points at: ${list.join(', ')}.`;
102
+ return `Point values: ${listed(list)}.`;
101
103
  }
102
104
  export const NETWORK_NAMES = {
103
105
  visa: 'Visa',
@@ -115,6 +117,7 @@ export const PAYMENT_PHRASES = {
115
117
  };
116
118
  export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
117
119
  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.`;
120
+ 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
121
  // For answers that never ask how each purchase is paid (cheat sheets and
119
122
  // simulations): which cards' rates that need a way of paying are left out.
120
123
  export function paymentNote(cards) {
@@ -146,6 +149,14 @@ export function withArticle(name) {
146
149
  : /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
147
150
  return `${an ? 'an' : 'a'} ${name}`;
148
151
  }
152
+ // A card's name as people say it: "Amex Blue Cash Preferred", not "American
153
+ // Express Blue Cash Preferred Card".
154
+ export const shortCardName = (name) => name
155
+ .replace(/^American Express /, 'Amex ')
156
+ .replace(/^The (.+) Card from American Express$/, 'Amex $1')
157
+ // "Card" is part of Apple Card's name, not a suffix.
158
+ .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
159
+ .replace(/ Card by /, ' by ');
149
160
  // "Amex Platinum's", "Capital One Savor Rewards'".
150
161
  export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
151
162
  export const listed = (parts, joiner = 'and') => {
@@ -196,6 +207,36 @@ export const PICK_PERIODS = {
196
207
  calendar_year: 'each year',
197
208
  until_changed: 'until you change it',
198
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
+ }
199
240
  // Intro rates (a card's first 365 days): "first year", "first 90 days".
200
241
  export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
201
242
  export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
@@ -225,14 +266,21 @@ export const CREDIT_THIS_PERIOD = {
225
266
  export const ENROLL_FIRST = 'enroll first';
226
267
  // The group of cards with a credit at the store, shown before the ranking.
227
268
  export const CREDIT_GROUP = 'With a statement credit';
269
+ // A credit the cardholder said they don't use, in list_wallet.
270
+ export const CREDIT_SKIPPED = "marked as one you don't use, so it isn't counted";
228
271
  // "$75 back each quarter"
229
272
  export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
273
+ const minPurchase = (amount) => `a purchase of ${wholeDollars(amount)} or more`;
274
+ // " on a purchase of $500 or more", " through Sep 30, 2027": the limits a
275
+ // credit's issuer sets beyond its amount and period.
276
+ export const creditTerms = (c) => `${c.min_amount === undefined ? '' : ` with ${minPurchase(c.min_amount)}`}${c.valid_to === undefined ? '' : ` through ${shortDate(c.valid_to)}`}`;
230
277
  // "$75 back each quarter, then 1x Amex Membership Rewards"
231
- export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
278
+ export const creditThen = (c, earn) => `${creditBack(c)}${creditTerms(c)}, then ${earn}`;
232
279
  // "$35 left this quarter"
233
280
  export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
234
281
  // A line for an answer that uses the card's credit, or says why it doesn't.
235
282
  export function creditAnswerText(c, dollars) {
283
+ const min = c.minAmount ?? null;
236
284
  const credit = `${c.name} credit (${creditBack(c)})`;
237
285
  if (!c.enrolled)
238
286
  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 +289,9 @@ export function creditAnswerText(c, dollars) {
241
289
  if (c.left <= 0)
242
290
  return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
243
291
  if (dollars === null)
244
- return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
292
+ return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it${min === null ? '' : ` (it needs ${minPurchase(min)})`}.`;
293
+ if (dollars <= 0 && min !== null)
294
+ return `Its ${credit} needs ${minPurchase(min)}, so it doesn't count here.`;
245
295
  return dollars < c.left
246
296
  ? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
247
297
  : `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
@@ -265,5 +315,60 @@ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c
265
315
  export const enrollCreditText = (card, names) => names.length === 1
266
316
  ? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
267
317
  : `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
268
- // Simulations, the cheat sheet and welcome-offer comparisons.
318
+ // Credits that need no enrolling, before the cardholder says whether they
319
+ // use them: they don't offset the annual fee until then.
320
+ export const askCreditText = (card, names) => names.length === 1
321
+ ? `Do you use ${possessive(card)} ${names[0] ?? ''} credit? Mark it used and it counts against the annual fee.`
322
+ : `Do you use ${possessive(card)} ${listed(names)} credits? Mark them used and they count against the annual fee.`;
323
+ // The cheat sheet: its picks are by rate alone.
269
324
  export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
325
+ // "$424 a year: Dunkin', Uber, Grubhub and Resy"
326
+ export const creditsYearText = (c) => `${wholeDollars(c.yearly)} a year: ${listed(c.names)}`;
327
+ // A held card's credits the cardholder uses, counted against its fee:
328
+ // "Amex Gold's credits you use (Dunkin' and Uber) count against its fee: $204 a year."
329
+ export const creditsCountedText = (card, fee, c) => fee > 0
330
+ ? `${possessive(card)} credits you use (${listed(c.names)}) count against its fee: ${wholeDollars(c.yearly)} a year.`
331
+ : `${possessive(card)} credits you use (${listed(c.names)}) add ${wholeDollars(c.yearly)} a year.`;
332
+ // Rankings of cards the person doesn't hold: the honest figure leaves
333
+ // credits out, and the one with them is shown beside it.
334
+ 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.";
335
+ 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.";
336
+ export const CREDITS_ONLY_IF = "They're worth that only if you'd spend at those places anyway.";
337
+ // "If you'd use all the statement credits on Amex Gold and Sapphire Reserve
338
+ // ($724 a year), the pair nets $X a year."
339
+ 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.`;
340
+ // "If you'd use all its statement credits ($424 a year: …), it nets $X a
341
+ // year." `adds` for a card beside the ones already held.
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) {
@@ -150,17 +143,25 @@ export function cardFields(card, held, input, today) {
150
143
  fields.milestone_usage = usage;
151
144
  }
152
145
  if (input.enrolled !== undefined) {
153
- const needing = card.credits
154
- .filter((c) => c.enroll === true)
155
- .map((c) => c.id);
146
+ const ids = card.credits.map((c) => c.id);
156
147
  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.`);
148
+ if (!ids.includes(id))
149
+ problems.push(ids.length > 0
150
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
151
+ : `${card.id} has no statement credits.`);
161
152
  }
162
153
  fields.enrolled = [...new Set(input.enrolled)];
163
154
  }
155
+ if (input.skippedCredits !== undefined) {
156
+ const ids = card.credits.map((c) => c.id);
157
+ for (const id of input.skippedCredits) {
158
+ if (!ids.includes(id))
159
+ problems.push(ids.length > 0
160
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(ids)}.`
161
+ : `${card.id} has no statement credits.`);
162
+ }
163
+ fields.skipped_credits = [...new Set(input.skippedCredits)];
164
+ }
164
165
  if (input.creditUsed !== undefined) {
165
166
  const usage = { ...held?.credit_usage };
166
167
  const anniversary = fields.anniversary ?? held?.anniversary ?? null;
@@ -251,6 +252,9 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
251
252
  : { milestone_usage: f.milestone_usage }),
252
253
  ...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
253
254
  ...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
255
+ ...(f.skipped_credits === undefined
256
+ ? {}
257
+ : { skipped_credits: f.skipped_credits }),
254
258
  ...(f.bonus ? { bonus: f.bonus } : {}),
255
259
  });
256
260
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -327,6 +331,21 @@ export function withMemberships(catalog, wallet, memberships) {
327
331
  notes: [],
328
332
  };
329
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
+ }
330
349
  // Point values in cents per point, by currency id; null goes back to
331
350
  // earnrate's default for that currency.
332
351
  export function withValuations(catalog, wallet, changes) {
@@ -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,
@@ -238,18 +240,33 @@ export function walletSummary(catalog, wallet, today) {
238
240
  return {
239
241
  memberships: wallet.memberships,
240
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
+ })),
241
247
  pointValues: pointValues(catalog, wallet),
242
248
  started: null,
243
249
  cards,
244
250
  };
245
251
  }
252
+ function usedCredits(catalog, card, held, today) {
253
+ const used = usedCreditsYear(catalog, card, held, today);
254
+ return {
255
+ usedCredits: used.yearly,
256
+ usedCreditsText: used.creditIds.length === 0
257
+ ? null
258
+ : creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, used),
259
+ };
260
+ }
246
261
  function creditSummaries(catalog, card, held, today) {
247
262
  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)}`}`;
263
+ const status = s.skipped
264
+ ? CREDIT_SKIPPED
265
+ : !s.enrolled
266
+ ? `${ENROLL_FIRST}, then mark it enrolled`
267
+ : s.left === null
268
+ ? "give the card's anniversary to track it"
269
+ : `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
253
270
  return {
254
271
  id: s.credit.id,
255
272
  name: s.name,
@@ -257,12 +274,14 @@ function creditSummaries(catalog, card, held, today) {
257
274
  period: s.period,
258
275
  enroll: s.credit.enroll === true,
259
276
  enrolled: s.enrolled,
277
+ skipped: s.skipped,
278
+ confirmed: s.confirmed,
260
279
  currentPeriod: s.key,
261
280
  until: s.until,
262
281
  used: s.used,
263
282
  left: s.left,
264
283
  tracked: held.credit_usage?.[s.credit.id] !== undefined,
265
- text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
284
+ text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}${creditTerms(s.credit)}: ${status}.`,
266
285
  };
267
286
  });
268
287
  }
@@ -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, 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) {
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 } = {}) {
@@ -35,7 +27,14 @@ export function cardText(catalog) {
35
27
  function requirementLines(cardId, r, until = null) {
36
28
  const lines = [];
37
29
  if (r.pick)
38
- 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]})`);
39
38
  if (r.activation !== null)
40
39
  lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
41
40
  if (r.membership !== null)
@@ -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
  }
@@ -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`, ` ${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
  }
@@ -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)
@@ -62,7 +64,12 @@ export function formatWalletSummary(summary) {
62
64
  const values = summary.pointValues.length > 0
63
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.`
64
66
  : "Point values: earnrate's defaults.";
65
- 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');
66
73
  }
67
74
  export function formatBooking(amount, name, // the card's short name
68
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')
@@ -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()
@@ -73,7 +77,7 @@ const cardFields = {
73
77
  .string()
74
78
  .regex(/^\d{2}-\d{2}$/)
75
79
  .optional()
76
- .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."),
77
81
  opened: IsoDate.nullable()
78
82
  .optional()
79
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.'),
@@ -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.'),
@@ -163,6 +171,7 @@ export function makeInputs(catalog) {
163
171
  .optional()
164
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.`),
165
173
  };
174
+ const SkippedIssuers = z.array(z.enum(issuers));
166
175
  const AllMemberships = Memberships.describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`);
167
176
  return {
168
177
  CategoryInput,
@@ -173,6 +182,7 @@ export function makeInputs(catalog) {
173
182
  OfferFilters,
174
183
  CatalogFilters,
175
184
  AllMemberships,
185
+ SkippedIssuers,
176
186
  };
177
187
  }
178
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
  }