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
@@ -1,8 +1,10 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
+ import { allCreditsYear } from "./credits.js";
2
3
  import { matchesFilters } from "./explore.js";
4
+ import { simulateAdditions, typicalSpendLines } from "./simulate.js";
3
5
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
6
  import { inEffect } from "./periods.js";
5
- import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
7
+ import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
6
8
  import { cardValuation } from "./valuation.js";
7
9
  function ineligibleText(catalog, wallet, notWith) {
8
10
  const held = wallet.cards
@@ -24,8 +26,27 @@ export function offerTerms(o, currencyName) {
24
26
  : 'with no spending required';
25
27
  return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
26
28
  }
27
- 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 }) {
28
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]));
29
50
  const paid = paidMemberships(catalog, {
30
51
  memberships: wallet.memberships,
31
52
  heldCards: [...held],
@@ -37,6 +58,8 @@ export function compareOffers(catalog, wallet, filters, today) {
37
58
  continue;
38
59
  if (!filters.includeHeld && held.has(card.id))
39
60
  continue;
61
+ if (skipped.has(card.issuer))
62
+ continue;
40
63
  if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
41
64
  continue;
42
65
  const offer = card.welcome_offer;
@@ -49,6 +72,27 @@ export function compareOffers(catalog, wallet, filters, today) {
49
72
  0;
50
73
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
51
74
  const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
75
+ const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
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;
52
96
  offers.push({
53
97
  cardId: card.id,
54
98
  cardName: card.name,
@@ -68,20 +112,30 @@ export function compareOffers(catalog, wallet, filters, today) {
68
112
  bonusValue: roundCents(value),
69
113
  membershipFee,
70
114
  ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
71
- firstYearValue: roundCents(value - (firstYearFee ?? 0) - membershipFee),
115
+ firstYearValue,
116
+ credits,
117
+ firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
118
+ plan,
72
119
  held: held.has(card.id),
73
120
  lastVerified: card.last_verified,
74
121
  verifiedBy: card.verified_by,
75
122
  });
76
123
  }
77
124
  offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
125
+ (b.plan?.twoYears ?? -Infinity) - (a.plan?.twoYears ?? -Infinity) ||
78
126
  b.firstYearValue - a.firstYearValue ||
79
127
  a.cardId.localeCompare(b.cardId));
80
128
  const notes = [
81
129
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
82
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.`,
83
- "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 any credits or perks.",
84
- CREDITS_LEFT_OUT,
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.",
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
+ : []),
85
139
  ];
86
- return { offers, withoutOffer, notes };
140
+ return { offers, withoutOffer, notes, typical: spending.typical };
87
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
  }
@@ -1,6 +1,6 @@
1
1
  import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
- import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
3
+ import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { introWindows, openedOn, withIntro } from "./intro.js";
5
5
  import { membershipName } from "./memberships.js";
6
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
@@ -9,7 +9,7 @@ import { creditAt, creditDollars } from "./credits.js";
9
9
  import { ruleWhat } from "./rule-text.js";
10
10
  import { rotationGap } from "./rotation.js";
11
11
  import { EPSILON, isClose } from "./compare.js";
12
- import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
12
+ import { amountUnknownText, CAP_ADJECTIVES, categoryPhrase, codesAs, creditAnswerText, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, possessive, roundCents, shortDate, wholeDollars, } from "./text.js";
13
13
  import { cardValuation } from "./valuation.js";
14
14
  export const STALE_AFTER_DAYS = 180;
15
15
  // The engine only tells the U.S. from everywhere else.
@@ -99,6 +99,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
99
99
  }
100
100
  : null;
101
101
  const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
102
+ const larger = amountUpgrade(card, held, ctx, evaluation.best.rate);
102
103
  const credit = options.credits === false
103
104
  ? null
104
105
  : creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
@@ -133,6 +134,15 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
133
134
  creditPct,
134
135
  }
135
136
  : null,
137
+ ifLarger: larger
138
+ ? {
139
+ amount: larger.amount,
140
+ rate: larger.nominalRate,
141
+ returnPct: (perDollar - evaluation.best.rate + larger.rate) *
142
+ valuation.centsPerUnit -
143
+ fee,
144
+ }
145
+ : null,
136
146
  });
137
147
  }
138
148
  entries.sort(compareEntries);
@@ -375,6 +385,15 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
375
385
  const currency = e.valuation.currency;
376
386
  assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
377
387
  }
388
+ for (const e of entries) {
389
+ if (!e.ifLarger || !winner)
390
+ continue;
391
+ if (e !== winner &&
392
+ e.ifLarger.returnPct <= winner.effectiveReturnPct + EPSILON)
393
+ continue;
394
+ const currency = e.valuation.currency;
395
+ assumptions.push(amountUnknownText(displayName(e), e.ifLarger.amount, earnText(catalog, e.ifLarger.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
396
+ }
378
397
  for (const e of entries) {
379
398
  if (!e.ifNew || !winner)
380
399
  continue;
@@ -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, 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.
@@ -31,22 +31,19 @@ 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);
42
44
  if (!choice)
43
45
  return [`your ${choiceName(m.choice)} picks`];
44
- const which = choice.pick === 1
45
- ? 'the category'
46
- : `the ${String(choice.pick)} categories`;
47
- return [
48
- `${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
49
- ];
46
+ return [choicePhrase(choice)];
50
47
  }
51
48
  const merchants = m.merchants ?? [];
52
49
  const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
@@ -85,7 +82,10 @@ export function shortenedMerchants(catalog, merchants) {
85
82
  }
86
83
  export function ruleWhat(catalog, rule, card, options = {}) {
87
84
  const payment = rule.requires?.payment;
88
- const what = listed(ruleParts(catalog, rule, card, options));
85
+ const parts = listed(ruleParts(catalog, rule, card, options));
86
+ const what = rule.min_amount === undefined || rule.match.any
87
+ ? parts
88
+ : `${parts}, on purchases of ${wholeDollars(rule.min_amount)} or more`;
89
89
  return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
90
90
  }
91
91
  // "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
@@ -141,6 +141,14 @@ export function creditName(catalog, credit) {
141
141
  ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
142
142
  ]);
143
143
  }
144
+ // A short name for a list of credits: the first store a credit names
145
+ // ("Grubhub" for the dining credit at five chains); creditName has them all.
146
+ export function creditLabel(catalog, credit) {
147
+ const [first, ...more] = credit.match.merchants ?? [];
148
+ return first === undefined || more.length === 0
149
+ ? creditName(catalog, credit)
150
+ : merchantName(catalog, first);
151
+ }
144
152
  // "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
145
153
  // the category's or merchant's own preposition.
146
154
  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 { 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
  })),
@@ -108,7 +109,7 @@ export function cardInfo(catalog, card, wallet, today) {
108
109
  period: m.period,
109
110
  points: m.points ?? null,
110
111
  rewards: m.rewards ?? [],
111
- text: milestoneWhat(m).replace(/^the /, ''),
112
+ text: `${milestoneWhat(m).replace(/^the /, '')}${milestoneStarts(m, today)}`,
112
113
  })),
113
114
  introRates: card.intro_rates.map((i) => ({
114
115
  id: i.id,
@@ -123,7 +124,7 @@ export function cardInfo(catalog, card, wallet, today) {
123
124
  amount: c.amount,
124
125
  period: c.period,
125
126
  enroll: c.enroll === true,
126
- text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
127
+ text: `${creditBack(c)} ${creditWhere(catalog, c)}${creditTerms(c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
127
128
  };
128
129
  }),
129
130
  };
@@ -172,12 +173,15 @@ 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.
180
- const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
183
+ const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
184
+ const missing = membershipsUsed(card).filter((m) => !has.some((held) => held === m || sameGroup(catalog, held, m)));
181
185
  if (missing.length > 0) {
182
186
  notes.push({
183
187
  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,
@@ -300,15 +325,16 @@ export function simulateCatalog(catalog, lines, options) {
300
325
  return { singles, pairs: foldTies(pairs).slice(0, 10) };
301
326
  }
302
327
  // The person's own wallet, set up as it would run all year: their picks carry
303
- // over, rotating categories get activated, caps start fresh.
304
- 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) {
305
331
  return {
306
332
  ...wallet,
307
- cards: wallet.cards.map((held) => {
333
+ cards: wallet.cards.flatMap((held) => {
308
334
  const card = catalog.cards.find((c) => c.id === held.id);
309
- const ideal = card
310
- ? idealHolding(card, start, held.chosen?.picks ?? {})
311
- : held;
335
+ if (!card)
336
+ return [];
337
+ const ideal = idealHolding(card, start, held.chosen?.picks ?? {});
312
338
  return {
313
339
  ...ideal,
314
340
  ...(held.checkout_label !== undefined
@@ -317,6 +343,14 @@ function projectedWallet(wallet, catalog, start) {
317
343
  ...(held.anniversary !== undefined
318
344
  ? { anniversary: held.anniversary }
319
345
  : {}),
346
+ ...(held.enrolled !== undefined ? { enrolled: held.enrolled } : {}),
347
+ ...(held.skipped_credits !== undefined
348
+ ? { skipped_credits: held.skipped_credits }
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
+ : {}),
320
354
  };
321
355
  }),
322
356
  };
@@ -328,7 +362,9 @@ export function simulateAdditions(catalog, wallet, lines, options) {
328
362
  const held = new Set(heldIds);
329
363
  const additions = [];
330
364
  for (const card of catalog.cards) {
331
- 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))
332
368
  continue;
333
369
  const simulation = bestSimulation(catalog, projected.cards, card, lines, {
334
370
  ...options,
@@ -339,13 +375,17 @@ export function simulateAdditions(catalog, wallet, lines, options) {
339
375
  const extraRewards = roundCents(simulation.rewards - current.rewards);
340
376
  const annualFee = card.annual_fee ?? 0;
341
377
  const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
378
+ const gain = roundCents(extraRewards - annualFee - membershipFee);
379
+ const credits = allCreditsYear(catalog, card, options.start).yearly;
342
380
  additions.push({
343
381
  cardId: card.id,
344
382
  picks: simulation.cards.at(-1)?.picks ?? {},
345
383
  extraRewards,
346
384
  annualFee,
347
385
  membershipFee,
348
- gain: roundCents(extraRewards - annualFee - membershipFee),
386
+ gain,
387
+ credits,
388
+ gainIfCreditsUsed: roundCents(gain + credits),
349
389
  simulation,
350
390
  });
351
391
  }