earnrate 0.2.12 → 0.2.14

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 (99) hide show
  1. package/README.md +20 -7
  2. package/data/CHANGELOG.md +197 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +28 -2
  5. package/data/cards/amex-blue-cash-preferred.yaml +19 -0
  6. package/data/cards/amex-business-gold.yaml +38 -3
  7. package/data/cards/amex-business-platinum.yaml +72 -3
  8. package/data/cards/amex-delta-skymiles-gold.yaml +34 -9
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +40 -9
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +42 -10
  11. package/data/cards/amex-gold.yaml +52 -0
  12. package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
  13. package/data/cards/amex-hilton-honors-business.yaml +14 -1
  14. package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
  15. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
  16. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  17. package/data/cards/amex-platinum.yaml +110 -0
  18. package/data/cards/apple-card.yaml +76 -0
  19. package/data/cards/bilt-obsidian.yaml +12 -2
  20. package/data/cards/bilt-palladium.yaml +12 -2
  21. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  22. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  23. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  24. package/data/cards/capital-one-venture-x.yaml +30 -2
  25. package/data/cards/chase-aeroplan.yaml +14 -0
  26. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  27. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  28. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  29. package/data/cards/chase-ink-business-cash.yaml +13 -0
  30. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  31. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  32. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  33. package/data/cards/chase-united-business.yaml +32 -0
  34. package/data/cards/chase-united-club-business.yaml +40 -0
  35. package/data/cards/chase-united-club.yaml +40 -0
  36. package/data/cards/chase-united-explorer.yaml +32 -0
  37. package/data/cards/chase-united-quest.yaml +40 -0
  38. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  39. package/data/cards/citi-strata-elite.yaml +20 -1
  40. package/data/cards/discover-it-cash-back.yaml +8 -5
  41. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  42. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  43. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  44. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  45. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  46. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  47. package/data/cards/usbank-altitude-connect.yaml +8 -2
  48. package/data/cards/usbank-altitude-go.yaml +8 -2
  49. package/data/cards/usbank-smartly.yaml +107 -0
  50. package/data/cards/venmo-credit-card.yaml +151 -0
  51. package/data/cards/verizon-visa-card.yaml +122 -0
  52. package/data/currencies.yaml +14 -0
  53. package/data/issuers.yaml +22 -0
  54. package/data/memberships.yaml +35 -0
  55. package/data/merchants/overrides.yaml +448 -0
  56. package/data/merchants/seed.yaml +1085 -66
  57. package/dist/cli/args.js +18 -0
  58. package/dist/cli/explore.js +10 -1
  59. package/dist/cli/run.js +4 -1
  60. package/dist/cli/wallet.js +31 -4
  61. package/dist/data/file-store.js +5 -2
  62. package/dist/data/integrity.js +22 -0
  63. package/dist/data/store.js +4 -1
  64. package/dist/data/wallet-edit.js +25 -6
  65. package/dist/engine/cheat-sheet.js +20 -7
  66. package/dist/engine/checklist.js +35 -11
  67. package/dist/engine/credits.js +65 -0
  68. package/dist/engine/evaluate.js +38 -11
  69. package/dist/engine/explore.js +72 -9
  70. package/dist/engine/offers.js +2 -1
  71. package/dist/engine/periods.js +10 -3
  72. package/dist/engine/recommend.js +101 -38
  73. package/dist/engine/record.js +42 -1
  74. package/dist/engine/rotation.js +4 -0
  75. package/dist/engine/rule-text.js +35 -7
  76. package/dist/engine/setup.js +17 -6
  77. package/dist/engine/simulate.js +13 -4
  78. package/dist/engine/text.js +89 -1
  79. package/dist/engine/wallet-change.js +37 -1
  80. package/dist/engine/wallet-summary.js +32 -4
  81. package/dist/format/card-text.js +4 -3
  82. package/dist/format/cards.js +6 -2
  83. package/dist/format/cheat-sheet.js +2 -0
  84. package/dist/format/explore.js +20 -4
  85. package/dist/format/recommendation.js +6 -2
  86. package/dist/format/wallet-yaml.js +11 -15
  87. package/dist/format/wallet.js +15 -7
  88. package/dist/import/analyze.js +7 -2
  89. package/dist/mcp/inputs.js +10 -0
  90. package/dist/mcp/prompts.js +9 -1
  91. package/dist/mcp/schemas.js +71 -1
  92. package/dist/mcp/tools/answers.js +11 -5
  93. package/dist/mcp/tools/wallet.js +15 -7
  94. package/dist/merchants/resolve.js +8 -0
  95. package/dist/schema/card.js +37 -3
  96. package/dist/schema/common.js +1 -1
  97. package/dist/schema/merchant.js +6 -0
  98. package/dist/schema/wallet.js +8 -0
  99. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { bonusStatus, countsTowardBonus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext } from "./evaluate.js";
4
+ import { creditAt, creditDollars } from "./credits.js";
4
5
  import { milestoneProgress } from "./milestones.js";
5
6
  import { roundCents } from "./text.js";
6
7
  // Books the spend against the cap of the highest-rate rule that matches, even
@@ -95,6 +96,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
95
96
  spend: p.milestone.spend,
96
97
  before: p.spent,
97
98
  after: roundCents(Math.max(0, p.spent + purchase.amount)),
99
+ started: !p.tracked,
98
100
  },
99
101
  ]);
100
102
  }
@@ -112,14 +114,53 @@ export function applyMilestoneBooking(wallet, b) {
112
114
  : c),
113
115
  };
114
116
  }
117
+ // The part of an enrolled merchant credit this purchase uses. None when the
118
+ // card has no credit here, it isn't enrolled, or it's used up.
119
+ export function creditBooking(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
120
+ const held = wallet.cards.find((c) => c.id === purchase.cardId);
121
+ const card = catalog.cards.find((c) => c.id === purchase.cardId);
122
+ if (!held || !card)
123
+ return null;
124
+ const state = creditAt(catalog, card, held, merchant, purchase.date);
125
+ const back = state ? creditDollars(state, purchase.amount) : null;
126
+ if (!state || state.key === null || back === null || back <= 0)
127
+ return null;
128
+ return {
129
+ cardId: card.id,
130
+ creditId: state.credit.id,
131
+ name: state.name,
132
+ period: state.key,
133
+ periodKind: state.period,
134
+ amount: state.amount,
135
+ before: state.used,
136
+ after: roundCents(state.used + back),
137
+ };
138
+ }
139
+ export function applyCreditBooking(wallet, b) {
140
+ return {
141
+ ...wallet,
142
+ cards: wallet.cards.map((c) => c.id === b.cardId
143
+ ? {
144
+ ...c,
145
+ credit_usage: {
146
+ ...c.credit_usage,
147
+ [b.creditId]: { period: b.period, used: b.after },
148
+ },
149
+ }
150
+ : c),
151
+ };
152
+ }
115
153
  export function bookPurchase(catalog, wallet, purchase, merchant) {
116
154
  const cap = capBooking(catalog, wallet, purchase, merchant);
117
155
  const bonus = bonusBooking(catalog, wallet, purchase);
118
156
  const milestones = milestoneBookings(catalog, wallet, purchase);
157
+ const credit = creditBooking(catalog, wallet, purchase, merchant);
119
158
  let next = cap ? applyBooking(wallet, cap) : wallet;
120
159
  if (bonus)
121
160
  next = applyBonusBooking(next, bonus);
122
161
  for (const m of milestones)
123
162
  next = applyMilestoneBooking(next, m);
124
- return { wallet: next, cap, bonus, milestones };
163
+ if (credit)
164
+ next = applyCreditBooking(next, credit);
165
+ return { wallet: next, cap, bonus, milestones, credit };
125
166
  }
@@ -1,4 +1,5 @@
1
1
  import { periodKey } from "./periods.js";
2
+ import { possessive } from "./text.js";
2
3
  // The period of the card's rotating categories that `date` falls in, when no
3
4
  // rule in the data covers it yet; null when it's covered or the card doesn't
4
5
  // rotate.
@@ -14,3 +15,6 @@ export function rotationGap(card, date) {
14
15
  }
15
16
  return null;
16
17
  }
18
+ // What answers say about a gap: "Freedom Flex's rotating categories for
19
+ // 2027-Q2 aren't in earnrate's data yet. ..."
20
+ export const rotationGapText = (name, gap) => `${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
@@ -1,15 +1,14 @@
1
1
  import { findMerchant } from "../merchants/resolve.js";
2
- import { categoryPhrase, choiceName, earnText, introPeriod, listed, num, words, } from "./text.js";
2
+ import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, 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.
6
- const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
6
+ const BOOKING = /^(Activity|Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
7
+ const plural = (kind) => kind === 'Activity' ? 'activities' : `${kind.toLowerCase()}s`;
7
8
  function merchantName(catalog, id) {
8
9
  const merchant = findMerchant(catalog, id);
9
10
  const name = merchant?.name ?? words(id);
10
- return merchant?.kind === 'booking'
11
- ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
12
- : name;
11
+ return merchant?.kind === 'booking' ? name.replace(BOOKING, plural) : name;
13
12
  }
14
13
  export function optionName(catalog, card, choiceId, option) {
15
14
  const o = card.choices.find((c) => c.id === choiceId)?.options[option];
@@ -29,7 +28,9 @@ export function ruleParts(catalog, rule) {
29
28
  return [
30
29
  rule.geography === 'foreign_only'
31
30
  ? 'purchases outside the U.S.'
32
- : 'everything else',
31
+ : rule.requires?.payment !== undefined
32
+ ? 'purchases'
33
+ : 'everything else',
33
34
  ];
34
35
  if (rule.match.choice !== undefined)
35
36
  return [`your ${choiceName(rule.match.choice)} picks`];
@@ -55,7 +56,9 @@ export function ruleParts(catalog, rule) {
55
56
  return parts.length > 0 ? parts : [words(rule.id)];
56
57
  }
57
58
  export function ruleWhat(catalog, rule) {
58
- return listed(ruleParts(catalog, rule));
59
+ const payment = rule.requires?.payment;
60
+ const what = listed(ruleParts(catalog, rule));
61
+ return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
59
62
  }
60
63
  // "double every rate for the first year", "an extra 3% cash back on your 3%
61
64
  // picks for the first year".
@@ -71,3 +74,28 @@ export function introWhat(catalog, card, intro) {
71
74
  : `${num(intro.multiply)} times ${on}`;
72
75
  return `${change} for the ${introPeriod(intro.days)}`;
73
76
  }
77
+ // What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
78
+ // Bookings through a travel site the credit also names go without saying:
79
+ // "Capital One Travel", not each kind of booking through it.
80
+ export function creditName(catalog, credit) {
81
+ const ids = credit.match.merchants ?? [];
82
+ const portals = ids
83
+ .map((m) => findMerchant(catalog, m))
84
+ .filter((m) => m?.kind === 'portal')
85
+ .map((m) => ` through ${m?.name ?? ''}`);
86
+ const merchants = ids.filter((m) => {
87
+ const merchant = findMerchant(catalog, m);
88
+ return !(merchant?.kind === 'booking' &&
89
+ portals.some((p) => merchant.name.endsWith(p)));
90
+ });
91
+ return listed([
92
+ ...merchants.map((m) => merchantName(catalog, m)),
93
+ ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
94
+ ]);
95
+ }
96
+ // "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
97
+ // the category's or merchant's own preposition.
98
+ export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
99
+ (credit.match.merchants ?? []).some((m) => findMerchant(catalog, m)?.preposition === 'on')
100
+ ? 'on'
101
+ : 'at'} ${creditName(catalog, credit)}`;
@@ -2,9 +2,9 @@ import { isActivated, pickedCategories } from "./evaluate.js";
2
2
  import { membership, membershipName, sameGroup } from "./memberships.js";
3
3
  import { milestoneWhat } from "./milestones.js";
4
4
  import { daysBetween } from "./periods.js";
5
- import { rotationGap } from "./rotation.js";
6
- import { introWhat } from "./rule-text.js";
7
- import { idList, listed } from "./text.js";
5
+ import { rotationGap, rotationGapText } from "./rotation.js";
6
+ import { creditName, creditWhere, introWhat } from "./rule-text.js";
7
+ import { creditBack, 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
@@ -111,6 +111,17 @@ export function cardInfo(catalog, card, wallet, today) {
111
111
  days: i.days,
112
112
  text: introWhat(catalog, card, i),
113
113
  })),
114
+ credits: card.credits.map((c) => {
115
+ const name = creditName(catalog, c);
116
+ return {
117
+ id: c.id,
118
+ name,
119
+ amount: c.amount,
120
+ period: c.period,
121
+ enroll: c.enroll === true,
122
+ text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
123
+ };
124
+ }),
114
125
  };
115
126
  }
116
127
  // Every word of the query has to match a word of the card's id, name or
@@ -157,7 +168,7 @@ export function setupNotes(catalog, wallet, held, today) {
157
168
  notes.push({
158
169
  kind: 'pick',
159
170
  text: choice.default && picked.has(choice.id)
160
- ? `${name}'s ${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}).`
171
+ ? `${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}).`
161
172
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
162
173
  });
163
174
  }
@@ -166,7 +177,7 @@ export function setupNotes(catalog, wallet, held, today) {
166
177
  if (missing.length > 0) {
167
178
  notes.push({
168
179
  kind: 'membership',
169
- text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
180
+ text: `Some of ${possessive(name)} rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
170
181
  });
171
182
  }
172
183
  const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
@@ -186,7 +197,7 @@ export function setupNotes(catalog, wallet, held, today) {
186
197
  if (gap !== null) {
187
198
  notes.push({
188
199
  kind: 'rotation',
189
- text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`,
200
+ text: rotationGapText(name, gap),
190
201
  });
191
202
  }
192
203
  return notes;
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { applyBooking, capBooking } from "./record.js";
7
7
  import { milestoneWhat } from "./milestones.js";
8
8
  import { rotationGap } from "./rotation.js";
9
- import { money, roundCents, wholeDollars } from "./text.js";
9
+ import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
10
10
  import { cardValuation } from "./valuation.js";
11
11
  import { EPSILON, HALF_CENT } from "./compare.js";
12
12
  const MONTHS = 12;
@@ -155,19 +155,22 @@ held = []) {
155
155
  const notes = [...milestoneNotes];
156
156
  for (const m of memberships)
157
157
  notes.push(membershipNote(catalog, m));
158
+ const paying = paymentNote(cards.map(({ card }) => ({ name: card.name, card })));
159
+ if (paying !== null)
160
+ notes.push(paying);
158
161
  const dates = monthDates(start);
159
162
  for (const { card } of cards) {
160
163
  const gaps = [
161
164
  ...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
162
165
  ];
163
166
  if (gaps.length > 0)
164
- notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
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.`);
165
168
  if (card.annual_fee === undefined)
166
- notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
169
+ notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
167
170
  else if (!held.includes(card.id) &&
168
171
  card.first_year_fee !== undefined &&
169
172
  card.first_year_fee !== card.annual_fee)
170
- notes.push(`${card.name}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
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.`);
171
174
  }
172
175
  return {
173
176
  cards: wallet.cards.map((h) => ({
@@ -240,6 +243,12 @@ function bestSimulation(catalog, base, card, lines, options) {
240
243
  }
241
244
  const costs = (s) => s.annualFees + s.membershipFees;
242
245
  const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
246
+ // Each card on its own with its best picks, best net first.
247
+ export function simulateSingles(catalog, cards, lines, options) {
248
+ return cards
249
+ .map((card) => bestSimulation(catalog, [], card, lines, options))
250
+ .sort(byNet);
251
+ }
243
252
  // Pairs that tie and share their first card differ only in an
244
253
  // interchangeable second card (several flat 2% cards), so they fold into one.
245
254
  function foldTies(pairs) {
@@ -101,8 +101,36 @@ export const NETWORK_NAMES = {
101
101
  discover: 'Discover',
102
102
  store: 'store card',
103
103
  };
104
+ // How a purchase is paid, mid-sentence: "2% cash back with Apple Pay".
105
+ export const PAYMENT_PHRASES = {
106
+ card: 'with the card itself',
107
+ 'apple-pay': 'with Apple Pay',
108
+ 'google-pay': 'with Google Pay',
109
+ paypal: 'through PayPal checkout',
110
+ };
111
+ export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
112
+ 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.`;
113
+ // For answers that never ask how each purchase is paid (cheat sheets and
114
+ // simulations): which cards' rates that need a way of paying are left out.
115
+ export function paymentNote(cards) {
116
+ const payments = new Set();
117
+ const names = [];
118
+ for (const { name, card } of cards) {
119
+ const needs = card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]);
120
+ if (needs.length === 0)
121
+ continue;
122
+ names.push(name);
123
+ for (const p of needs)
124
+ payments.add(p);
125
+ }
126
+ if (names.length === 0)
127
+ return null;
128
+ return `Rates ${listed([...payments].map((p) => PAYMENT_PHRASES[p]), 'or')} (${listed(names)}) aren't counted: this ranks each card by what it earns however you pay.`;
129
+ }
104
130
  // A name with its own "and" ("drugstores and pharmacies") would run into the
105
131
  // list's: those lists use commas only, with those names last.
132
+ // "Amex Platinum's", "Capital One Savor Rewards'".
133
+ export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
106
134
  export const listed = (parts, joiner = 'and') => {
107
135
  const anded = parts.filter((p) => p.includes(' and '));
108
136
  if (anded.length > 0)
@@ -161,4 +189,64 @@ export const introSince = (days) => days === 365 ? 'in the last year' : `in the
161
189
  export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
162
190
  // For a card whose opening date isn't known: it's never assumed to be new.
163
191
  export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
164
- export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
192
+ export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
193
+ // Merchant statement credits. Always dollars, never a percentage.
194
+ export const CREDIT_PERIODS = {
195
+ month: 'each month',
196
+ quarter: 'each quarter',
197
+ half_year: 'each half year',
198
+ calendar_year: 'each calendar year',
199
+ cardmember_year: 'each cardmember year',
200
+ };
201
+ export const CREDIT_THIS_PERIOD = {
202
+ month: 'this month',
203
+ quarter: 'this quarter',
204
+ half_year: 'this half year',
205
+ calendar_year: 'this year',
206
+ cardmember_year: 'this cardmember year',
207
+ };
208
+ export const ENROLL_FIRST = 'enroll first';
209
+ // The group of cards with a credit at the store, shown before the ranking.
210
+ export const CREDIT_GROUP = 'With a store credit';
211
+ // "$75 back each quarter"
212
+ export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
213
+ // "$75 back each quarter, then 1x Amex Membership Rewards"
214
+ export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
215
+ // "$35 left this quarter"
216
+ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
217
+ // A line for an answer that uses the card's credit, or says why it doesn't.
218
+ export function creditAnswerText(c, dollars) {
219
+ const credit = `${c.name} credit (${creditBack(c)})`;
220
+ if (!c.enrolled)
221
+ return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
222
+ if (c.left === null)
223
+ return `Has a ${credit} here; give the card's anniversary to track it.`;
224
+ if (c.left <= 0)
225
+ return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
226
+ if (dollars === null)
227
+ return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
228
+ return dollars < c.left
229
+ ? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
230
+ : `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
231
+ }
232
+ // One short line beside a ranked card: "+ $35 back from its Lululemon
233
+ // credit", "Lululemon credit: $35 left this quarter".
234
+ export function creditShort(c, dollars) {
235
+ if (!c.enrolled)
236
+ return `${c.name} credit: ${creditBack(c)}, ${ENROLL_FIRST}`;
237
+ if (c.left === null)
238
+ return `${c.name} credit: ${creditBack(c)}`;
239
+ if (c.left <= 0)
240
+ return `${c.name} credit used up ${CREDIT_THIS_PERIOD[c.period]}`;
241
+ if (dollars === null || dollars <= 0)
242
+ return `${c.name} credit: ${creditLeft(c.left, c.period)}`;
243
+ return `+ ${money(dollars)} back from its ${c.name} credit`;
244
+ }
245
+ // For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
246
+ // 31, 2026: $35 left."
247
+ export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
248
+ export const enrollCreditText = (card, names) => names.length === 1
249
+ ? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
250
+ : `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
251
+ // Simulations, the cheat sheet and welcome-offer comparisons.
252
+ export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
@@ -4,7 +4,7 @@ import { addMonths } from "./bonus.js";
4
4
  import { capByKey } from "./evaluate.js";
5
5
  import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
7
- import { idList } from "./text.js";
7
+ import { CREDIT_PERIODS, idList, money } from "./text.js";
8
8
  const failed = (error) => ({ ok: false, error });
9
9
  function findCatalogCard(catalog, id, today) {
10
10
  const card = catalog.cards.find((c) => c.id === id);
@@ -149,6 +149,40 @@ export function cardFields(card, held, input, today) {
149
149
  }
150
150
  fields.milestone_usage = usage;
151
151
  }
152
+ if (input.enrolled !== undefined) {
153
+ const needing = card.credits
154
+ .filter((c) => c.enroll === true)
155
+ .map((c) => c.id);
156
+ 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.`);
161
+ }
162
+ fields.enrolled = [...new Set(input.enrolled)];
163
+ }
164
+ if (input.creditUsed !== undefined) {
165
+ const usage = { ...held?.credit_usage };
166
+ const anniversary = fields.anniversary ?? held?.anniversary ?? null;
167
+ for (const [id, used] of Object.entries(input.creditUsed)) {
168
+ const credit = card.credits.find((c) => c.id === id);
169
+ if (!credit) {
170
+ problems.push(card.credits.length > 0
171
+ ? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
172
+ : `${card.id} has no merchant credits.`);
173
+ continue;
174
+ }
175
+ if (!(used >= 0 && used <= credit.amount))
176
+ problems.push(`The '${id}' credit is ${money(credit.amount)} ${CREDIT_PERIODS[credit.period]}, so what's used must be $0 to ${money(credit.amount)}.`);
177
+ const period = periodKey(credit.period, today, anniversary);
178
+ if (period === null) {
179
+ problems.push(`The '${id}' credit resets on the card's anniversary; give the anniversary (MM-DD) too.`);
180
+ continue;
181
+ }
182
+ usage[id] = { period, used };
183
+ }
184
+ fields.credit_usage = usage;
185
+ }
152
186
  if (input.bonus === null)
153
187
  fields.bonus = null;
154
188
  else if (input.bonus !== undefined) {
@@ -215,6 +249,8 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
215
249
  ...(f.milestone_usage === undefined
216
250
  ? {}
217
251
  : { milestone_usage: f.milestone_usage }),
252
+ ...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
253
+ ...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
218
254
  ...(f.bonus ? { bonus: f.bonus } : {}),
219
255
  });
220
256
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -3,12 +3,13 @@ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./eval
3
3
  import { introUntil, introWindows, openedOn } from "./intro.js";
4
4
  import { membershipName } from "./memberships.js";
5
5
  import { milestoneSummaries } from "./milestones.js";
6
- import { optionName, ruleParts } from "./rule-text.js";
6
+ import { creditStates } from "./credits.js";
7
+ import { creditWhere, optionName, ruleParts } from "./rule-text.js";
7
8
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
8
9
  import { STALE_AFTER_DAYS } from "./recommend.js";
9
10
  import { rotationGap } from "./rotation.js";
10
11
  import { activationRules } from "./setup.js";
11
- import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
12
+ import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
12
13
  import { cardValuation, currencyValuation } from "./valuation.js";
13
14
  const matchKeys = (rule) => rule.match.choice !== undefined
14
15
  ? []
@@ -62,10 +63,12 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
62
63
  if (replaced)
63
64
  continue;
64
65
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
66
+ const payment = r.requires?.payment;
65
67
  const key = [
66
68
  `${earnText(catalog, r.rate, currency)} on`,
67
69
  capLimit(card, shown, r),
68
70
  pending ? ', once activated' : '',
71
+ payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
69
72
  ].join('|');
70
73
  const whats = lines.get(key) ?? [];
71
74
  for (const part of ruleParts(catalog, r))
@@ -75,12 +78,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
75
78
  }
76
79
  return [
77
80
  ...[...lines].map(([key, whats]) => {
78
- const [head = '', limit = '', activate = ''] = key.split('|');
81
+ const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
79
82
  const after = `${limit}${activate}`;
80
- return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
83
+ return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
81
84
  }),
82
85
  ...(shown.some((r) => r.match.any &&
83
86
  r.geography !== 'foreign_only' &&
87
+ r.requires?.payment === undefined &&
84
88
  r.rate > card.base_rate)
85
89
  ? []
86
90
  : [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
@@ -200,6 +204,7 @@ export function walletSummary(catalog, wallet, today) {
200
204
  rotationGap: rotationGap(card, today),
201
205
  caps: capSummaries(catalog, card, held, today),
202
206
  milestones: milestoneSummaries(card, held, today),
207
+ credits: creditSummaries(catalog, card, held, today),
203
208
  chosen: held.chosen
204
209
  ? {
205
210
  period: held.chosen.period,
@@ -229,6 +234,29 @@ export function walletSummary(catalog, wallet, today) {
229
234
  cards,
230
235
  };
231
236
  }
237
+ function creditSummaries(catalog, card, held, today) {
238
+ return creditStates(catalog, card, held, today).map((s) => {
239
+ const status = !s.enrolled
240
+ ? `${ENROLL_FIRST}, then mark it enrolled`
241
+ : s.left === null
242
+ ? "give the card's anniversary to track it"
243
+ : `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
244
+ return {
245
+ id: s.credit.id,
246
+ name: s.name,
247
+ amount: s.amount,
248
+ period: s.period,
249
+ enroll: s.credit.enroll === true,
250
+ enrolled: s.enrolled,
251
+ currentPeriod: s.key,
252
+ until: s.until,
253
+ used: s.used,
254
+ left: s.left,
255
+ tracked: held.credit_usage?.[s.credit.id] !== undefined,
256
+ text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
257
+ };
258
+ });
259
+ }
232
260
  function introSummary(card, held, today) {
233
261
  const [first] = card.intro_rates;
234
262
  if (!first)
@@ -9,11 +9,12 @@ export function cardText(catalog) {
9
9
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
10
10
  function shortName(id) {
11
11
  const name = cards.get(id)?.name ?? id;
12
- return name
12
+ return (name
13
13
  .replace(/^American Express /, 'Amex ')
14
14
  .replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
15
- .replace(/ (Visa Signature |Visa Infinite )?(Credit )?Card$/, '')
16
- .replace(/ Card by /, ' by ');
15
+ // "Card" is part of Apple Card's name, not a suffix.
16
+ .replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
17
+ .replace(/ Card by /, ' by '));
17
18
  }
18
19
  function feeLabel(id) {
19
20
  const card = cards.get(id);
@@ -1,5 +1,6 @@
1
+ import { membershipName } from "../engine/memberships.js";
1
2
  import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
2
- export function formatCardInfo(card) {
3
+ export function formatCardInfo(catalog, card) {
3
4
  const fee = card.annualFee === null
4
5
  ? ''
5
6
  : card.annualFee === 0
@@ -13,7 +14,9 @@ export function formatCardInfo(card) {
13
14
  ]
14
15
  : []),
15
16
  ...(card.memberships.length > 0
16
- ? [`rates that need a membership: ${card.memberships.join(', ')}`]
17
+ ? [
18
+ `rates that need a membership: ${card.memberships.map((id) => `${membershipName(catalog, id)} (membership id ${id})`).join(', ')}`,
19
+ ]
17
20
  : []),
18
21
  ...(card.activation.length > 0
19
22
  ? [`activate with the issuer: ${card.activation.join(', ')}`]
@@ -21,6 +24,7 @@ export function formatCardInfo(card) {
21
24
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
22
25
  ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
23
26
  ...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
27
+ ...card.credits.map((c) => `merchant credit ${c.id}: ${c.text}`),
24
28
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
25
29
  foreignFeeText(card.foreignTransactionFee).toLowerCase(),
26
30
  ];
@@ -15,5 +15,7 @@ export function formatCheatSheet(sheet) {
15
15
  }
16
16
  if (sheet.notes.length > 0)
17
17
  lines.push('', ...sheet.notes);
18
+ if (sheet.assumptions.length > 0)
19
+ lines.push('', ...sheet.assumptions);
18
20
  return `${lines.join('\n')}\n`;
19
21
  }
@@ -1,5 +1,5 @@
1
1
  import { membershipName, membershipNote, } from "../engine/memberships.js";
2
- import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
2
+ import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
3
3
  const membershipFees = (memberships) => memberships
4
4
  .map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
5
5
  .join('');
@@ -15,7 +15,23 @@ export function formatExploration(r, catalog, all) {
15
15
  return `No cards match at ${title}.\n`;
16
16
  const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
17
17
  const width = Math.max(...shown.map((e) => e.cardName.length));
18
- const lines = [`Best cards for ${title}:`, ''];
18
+ const lines = [
19
+ ...(r.credits.length === 0
20
+ ? []
21
+ : [
22
+ `${CREDIT_GROUP} at ${title}:`,
23
+ '',
24
+ ...r.credits.flatMap((c) => [
25
+ `- ${c.cardName}: ${c.text}${c.enroll ? ` (${ENROLL_FIRST})` : ''}. ${capitalize(feeText(c))}.`,
26
+ ...(c.evidence
27
+ ? [` "${c.evidence.quote}" (${hostname(c.evidence.url)})`]
28
+ : []),
29
+ ]),
30
+ '',
31
+ ]),
32
+ `Best cards for ${title}${r.credits.length === 0 ? '' : ' by rate'}:`,
33
+ '',
34
+ ];
19
35
  shown.forEach((e, i) => {
20
36
  const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
21
37
  lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
@@ -89,7 +105,7 @@ export function formatCatalogSimulation(r, catalog, all) {
89
105
  ]);
90
106
  if (notes.size > 0)
91
107
  lines.push('', ...notes);
92
- lines.push('', "Assumes each card's best picks, activations done each quarter, and no welcome bonuses.");
108
+ lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
93
109
  return `${lines.join('\n')}\n`;
94
110
  }
95
111
  export function formatAdditions(r, catalog, all) {
@@ -117,6 +133,6 @@ export function formatAdditions(r, catalog, all) {
117
133
  ]);
118
134
  if (notes.size > 0)
119
135
  lines.push('', ...notes);
120
- 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. 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.');
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.`);
121
137
  return `${lines.join('\n')}\n`;
122
138
  }
@@ -33,7 +33,8 @@ function cardLines(cards, style, bonusPick) {
33
33
  const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
34
34
  (c.milestoneBonus === null
35
35
  ? ''
36
- : ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
36
+ : ` + ${money(c.milestoneBonus.dollars)} spend bonus`) +
37
+ (c.credit?.dollars ? ` + ${money(c.credit.dollars)} credit` : '');
37
38
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
38
39
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
39
40
  for (const r of c.reasoning)
@@ -59,9 +60,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
59
60
  const milestone = top.milestoneBonus === null
60
61
  ? ''
61
62
  : `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
63
+ const credit = top.credit?.dollars
64
+ ? `, plus ${money(top.credit.dollars)} back from its ${top.credit.name} credit`
65
+ : '';
62
66
  const confidence = rec.decision.confidence;
63
67
  const why = rec.bonus ? ' for its welcome bonus' : '';
64
- lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
68
+ lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}${credit}.`)} ${capitalize(confidence)} confidence.`);
65
69
  if (rec.bonus && rec.notes[0])
66
70
  lines.push(rec.notes[0]);
67
71
  }