earnrate 0.2.11 → 0.2.13

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 (158) hide show
  1. package/README.md +37 -12
  2. package/data/CHANGELOG.md +291 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +1 -0
  5. package/data/cards/amex-blue-cash-everyday.yaml +29 -2
  6. package/data/cards/amex-blue-cash-preferred.yaml +26 -0
  7. package/data/cards/amex-business-gold.yaml +169 -0
  8. package/data/cards/amex-business-platinum.yaml +184 -0
  9. package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
  10. package/data/cards/amex-delta-skymiles-gold.yaml +29 -1
  11. package/data/cards/amex-delta-skymiles-platinum.yaml +35 -1
  12. package/data/cards/amex-delta-skymiles-reserve.yaml +35 -1
  13. package/data/cards/amex-gold.yaml +53 -0
  14. package/data/cards/amex-hilton-honors-aspire.yaml +25 -1
  15. package/data/cards/amex-hilton-honors-business.yaml +106 -0
  16. package/data/cards/amex-hilton-honors-surpass.yaml +16 -1
  17. package/data/cards/amex-hilton-honors.yaml +2 -0
  18. package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
  19. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +17 -1
  20. package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
  21. package/data/cards/amex-platinum.yaml +111 -0
  22. package/data/cards/apple-card.yaml +76 -0
  23. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
  24. package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
  25. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
  26. package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
  27. package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
  28. package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
  29. package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
  30. package/data/cards/barclays-jetblue-plus.yaml +86 -0
  31. package/data/cards/bilt-blue.yaml +60 -0
  32. package/data/cards/bilt-obsidian.yaml +13 -2
  33. package/data/cards/bilt-palladium.yaml +86 -0
  34. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  35. package/data/cards/capital-one-kohls-card.yaml +1 -0
  36. package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
  37. package/data/cards/capital-one-quicksilver.yaml +2 -0
  38. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  39. package/data/cards/capital-one-savor.yaml +2 -0
  40. package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
  41. package/data/cards/capital-one-venture-x-business.yaml +124 -0
  42. package/data/cards/capital-one-venture-x.yaml +32 -2
  43. package/data/cards/capital-one-venture.yaml +2 -0
  44. package/data/cards/chase-aeroplan.yaml +16 -0
  45. package/data/cards/chase-freedom-flex.yaml +1 -0
  46. package/data/cards/chase-freedom-rise.yaml +29 -0
  47. package/data/cards/chase-freedom-unlimited.yaml +1 -0
  48. package/data/cards/chase-ihg-one-rewards-business.yaml +28 -1
  49. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +18 -0
  50. package/data/cards/chase-ihg-one-rewards-premier.yaml +16 -0
  51. package/data/cards/chase-ihg-one-rewards.yaml +1 -0
  52. package/data/cards/chase-ink-business-cash.yaml +14 -0
  53. package/data/cards/chase-ink-business-preferred.yaml +1 -0
  54. package/data/cards/chase-ink-business-unlimited.yaml +80 -0
  55. package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
  56. package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
  57. package/data/cards/chase-prime-visa.yaml +1 -0
  58. package/data/cards/chase-sapphire-preferred.yaml +12 -0
  59. package/data/cards/chase-sapphire-reserve.yaml +45 -4
  60. package/data/cards/chase-southwest-performance-business.yaml +2 -0
  61. package/data/cards/chase-southwest-plus.yaml +2 -0
  62. package/data/cards/chase-southwest-premier-business.yaml +2 -0
  63. package/data/cards/chase-southwest-premier.yaml +2 -0
  64. package/data/cards/chase-southwest-priority.yaml +2 -0
  65. package/data/cards/chase-united-business.yaml +33 -0
  66. package/data/cards/chase-united-club-business.yaml +41 -0
  67. package/data/cards/chase-united-club.yaml +41 -0
  68. package/data/cards/chase-united-explorer.yaml +33 -0
  69. package/data/cards/chase-united-gateway.yaml +1 -0
  70. package/data/cards/chase-united-quest.yaml +41 -0
  71. package/data/cards/citi-aadvantage-business.yaml +2 -0
  72. package/data/cards/citi-aadvantage-executive.yaml +28 -1
  73. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  74. package/data/cards/citi-aadvantage-mileup.yaml +2 -0
  75. package/data/cards/citi-aadvantage-platinum-select.yaml +2 -0
  76. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  77. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  78. package/data/cards/citi-custom-cash.yaml +1 -0
  79. package/data/cards/citi-double-cash.yaml +1 -0
  80. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  81. package/data/cards/citi-strata-elite.yaml +21 -1
  82. package/data/cards/citi-strata-premier.yaml +1 -0
  83. package/data/cards/citi-strata.yaml +164 -0
  84. package/data/cards/discover-it-cash-back.yaml +22 -3
  85. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  86. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  87. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  88. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  89. package/data/cards/paypal-cashback-mastercard.yaml +52 -0
  90. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  91. package/data/cards/sams-club-mastercard.yaml +68 -0
  92. package/data/cards/sofi-unlimited-2.yaml +1 -0
  93. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  94. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  95. package/data/cards/td-bank-target-circle-card.yaml +1 -0
  96. package/data/cards/usbank-altitude-connect.yaml +156 -0
  97. package/data/cards/usbank-altitude-go.yaml +124 -0
  98. package/data/cards/usbank-cash-plus.yaml +1 -0
  99. package/data/cards/usbank-smartly.yaml +107 -0
  100. package/data/cards/venmo-credit-card.yaml +151 -0
  101. package/data/cards/verizon-visa-card.yaml +122 -0
  102. package/data/cards/wells-fargo-active-cash.yaml +1 -0
  103. package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
  104. package/data/cards/wells-fargo-autograph.yaml +1 -0
  105. package/data/categories.yaml +3 -1
  106. package/data/currencies.yaml +48 -0
  107. package/data/issuers.yaml +44 -1
  108. package/data/memberships.yaml +45 -0
  109. package/data/merchants/overrides.yaml +947 -1
  110. package/data/merchants/seed.yaml +1916 -62
  111. package/dist/cli/args.js +18 -0
  112. package/dist/cli/explore.js +10 -1
  113. package/dist/cli/import.js +2 -2
  114. package/dist/cli/run.js +4 -1
  115. package/dist/cli/wallet.js +37 -3
  116. package/dist/data/file-store.js +5 -2
  117. package/dist/data/integrity.js +59 -0
  118. package/dist/data/store.js +4 -1
  119. package/dist/data/wallet-edit.js +28 -7
  120. package/dist/engine/cheat-sheet.js +17 -5
  121. package/dist/engine/checklist.js +38 -6
  122. package/dist/engine/credits.js +65 -0
  123. package/dist/engine/evaluate.js +57 -18
  124. package/dist/engine/explore.js +70 -7
  125. package/dist/engine/intro.js +36 -0
  126. package/dist/engine/offers.js +2 -1
  127. package/dist/engine/periods.js +10 -3
  128. package/dist/engine/recommend.js +101 -20
  129. package/dist/engine/record.js +43 -1
  130. package/dist/engine/rotation.js +3 -0
  131. package/dist/engine/rule-text.js +28 -3
  132. package/dist/engine/setup.js +20 -3
  133. package/dist/engine/simulate.js +10 -1
  134. package/dist/engine/text.js +107 -0
  135. package/dist/engine/wallet-change.js +57 -4
  136. package/dist/engine/wallet-summary.js +54 -6
  137. package/dist/format/card-art.js +21 -0
  138. package/dist/format/card-text.js +4 -3
  139. package/dist/format/cards.js +1 -0
  140. package/dist/format/cheat-sheet.js +2 -0
  141. package/dist/format/explore.js +20 -4
  142. package/dist/format/recommendation.js +6 -2
  143. package/dist/format/wallet-yaml.js +1 -0
  144. package/dist/format/wallet.js +15 -5
  145. package/dist/import/analyze.js +8 -2
  146. package/dist/mcp/inputs.js +13 -0
  147. package/dist/mcp/prompts.js +9 -1
  148. package/dist/mcp/schemas.js +87 -1
  149. package/dist/mcp/tools/account.js +3 -1
  150. package/dist/mcp/tools/answers.js +9 -4
  151. package/dist/mcp/tools/wallet.js +20 -8
  152. package/dist/merchants/resolve.js +8 -0
  153. package/dist/schema/card.js +60 -3
  154. package/dist/schema/common.js +1 -1
  155. package/dist/schema/issuer.js +4 -0
  156. package/dist/schema/merchant.js +3 -0
  157. package/dist/schema/wallet.js +11 -0
  158. package/package.json +12 -3
@@ -1,13 +1,15 @@
1
- import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
1
+ import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
- import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
3
+ import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { openedOn } from "./intro.js";
4
5
  import { membershipName } from "./memberships.js";
5
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
6
7
  import { daysBetween } from "./periods.js";
8
+ import { creditAt, creditDollars } from "./credits.js";
7
9
  import { ruleWhat } from "./rule-text.js";
8
10
  import { rotationGap } from "./rotation.js";
9
11
  import { EPSILON, isClose } from "./compare.js";
10
- import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
12
+ import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
11
13
  import { cardValuation } from "./valuation.js";
12
14
  export const STALE_AFTER_DAYS = 180;
13
15
  // The engine only tells the U.S. from everywhere else.
@@ -41,7 +43,7 @@ function compareEntries(a, b) {
41
43
  return aCapped ? 1 : -1;
42
44
  return a.card.id.localeCompare(b.card.id);
43
45
  }
44
- function rankEntries(catalog, wallet, purchase, merchant, category) {
46
+ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
45
47
  const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
46
48
  const entries = [];
47
49
  const excluded = [];
@@ -79,6 +81,26 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
79
81
  (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
80
82
  // An unknown fee isn't guessed: it's warned about instead.
81
83
  const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
84
+ const fresh = card.intro_rates.length > 0 && openedOn(held) === null
85
+ ? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
86
+ : null;
87
+ const ifNew = fresh && fresh.rate > evaluation.best.rate + EPSILON
88
+ ? {
89
+ rate: fresh.nominalRate,
90
+ returnPct: (perDollar - evaluation.best.rate + fresh.rate) *
91
+ valuation.centsPerUnit -
92
+ fee,
93
+ days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
94
+ }
95
+ : null;
96
+ const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
97
+ const credit = options.credits === false
98
+ ? null
99
+ : creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
100
+ const back = credit ? creditDollars(credit, purchase.amount) : null;
101
+ const creditPct = back !== null && back > 0 && purchase.amount
102
+ ? (back / purchase.amount) * 100
103
+ : 0;
82
104
  entries.push({
83
105
  card,
84
106
  held,
@@ -87,9 +109,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
87
109
  milestones,
88
110
  perDollar,
89
111
  fee,
90
- effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
112
+ credit,
113
+ creditDollars: back,
114
+ creditPct,
115
+ effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
91
116
  rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
92
- upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
117
+ upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
118
+ fee +
119
+ creditPct,
120
+ ifNew: ifNew && { ...ifNew, returnPct: ifNew.returnPct + creditPct },
121
+ ifPaid: paid
122
+ ? {
123
+ payment: paid.payment,
124
+ rate: paid.nominalRate,
125
+ returnPct: (perDollar - evaluation.best.rate + paid.rate) *
126
+ valuation.centsPerUnit -
127
+ fee +
128
+ creditPct,
129
+ }
130
+ : null,
93
131
  });
94
132
  }
95
133
  entries.sort(compareEntries);
@@ -105,7 +143,12 @@ function reasoningFor(catalog, wallet, entry, amount) {
105
143
  lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
106
144
  }
107
145
  else {
108
- lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
146
+ lines.push(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
147
+ }
148
+ const [intro] = best.intro;
149
+ if (intro) {
150
+ const note = intro.intro.note ? ` ${intro.intro.note}` : '';
151
+ lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
109
152
  }
110
153
  const cap = best.cap;
111
154
  if (cap && amount !== null && amount > cap.remaining) {
@@ -114,6 +157,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
114
157
  }
115
158
  for (const hit of entry.milestones)
116
159
  lines.push(reachedText(card, hit, valuation.centsPerUnit));
160
+ if (entry.credit)
161
+ lines.push(creditAnswerText(entry.credit, entry.creditDollars));
117
162
  if (valuation.pooledFrom !== null) {
118
163
  const partners = card.pools_into?.when_wallet_has ?? [];
119
164
  const heldPartners = wallet.cards
@@ -199,6 +244,16 @@ function toRanked(catalog, wallet, entry, purchase) {
199
244
  entry.valuation.centsPerUnit) /
200
245
  100),
201
246
  },
247
+ credit: entry.credit && {
248
+ id: entry.credit.credit.id,
249
+ name: entry.credit.name,
250
+ amount: entry.credit.amount,
251
+ period: entry.credit.period,
252
+ enrolled: entry.credit.enrolled,
253
+ left: entry.credit.left,
254
+ until: entry.credit.until,
255
+ dollars: entry.creditDollars,
256
+ },
202
257
  capped: evaluation.best.cap !== null,
203
258
  reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
204
259
  warnings: warningsFor(catalog, entry, purchase),
@@ -243,15 +298,16 @@ function sensitivity(catalog, entries) {
243
298
  continue;
244
299
  const wRate = winner.perDollar;
245
300
  const oRate = other.perDollar;
246
- // Net return is rate × value − fee, so each flip point adds the fee back.
301
+ // Net return is rate × value − fee + credit, so each flip point adds the
302
+ // fee back and takes the credit off.
247
303
  if (w.range && wRate > 0) {
248
- const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
304
+ const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
249
305
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
250
306
  consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
251
307
  }
252
308
  }
253
309
  if (o.range && oRate > 0) {
254
- const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
310
+ const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
255
311
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
256
312
  consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
257
313
  }
@@ -273,8 +329,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
273
329
  const naming = entries.flatMap((e) => {
274
330
  const rule = e.evaluation.best.rule;
275
331
  return rule &&
276
- merchant.merchantId !== null &&
277
- (rule.match.merchants ?? []).includes(merchant.merchantId)
332
+ merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
278
333
  ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
279
334
  : [];
280
335
  });
@@ -291,6 +346,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
291
346
  if (purchase.channel === null && merchant.channel === 'in_store') {
292
347
  assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
293
348
  }
349
+ if (merchant.channel === null && purchase.payment === 'paypal') {
350
+ assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
351
+ }
294
352
  if (purchase.channel === null && merchant.channel === 'online') {
295
353
  assumptions.push('Assumes an online purchase, since you gave a web address.');
296
354
  }
@@ -299,13 +357,34 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
299
357
  assumptions.push(values);
300
358
  if (isForeign(purchase))
301
359
  assumptions.push(FOREIGN_ASSUMPTION);
360
+ if (purchase.payment)
361
+ assumptions.push(paidText(purchase.payment));
362
+ const winner = entries[0];
363
+ for (const e of entries) {
364
+ if (!e.ifPaid || !winner)
365
+ continue;
366
+ if (e !== winner &&
367
+ e.ifPaid.returnPct <= winner.effectiveReturnPct + EPSILON)
368
+ continue;
369
+ const currency = e.valuation.currency;
370
+ assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
371
+ }
372
+ for (const e of entries) {
373
+ if (!e.ifNew || !winner)
374
+ continue;
375
+ if (e !== winner &&
376
+ e.ifNew.returnPct <= winner.effectiveReturnPct + EPSILON)
377
+ continue;
378
+ const currency = e.valuation.currency;
379
+ assumptions.push(introUnknownText(displayName(e), earnText(catalog, e.ifNew.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency), e.ifNew.days));
380
+ }
302
381
  for (const e of entries)
303
382
  for (const hit of e.milestones)
304
383
  assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
305
384
  return assumptions;
306
385
  }
307
- export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
308
- const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category);
386
+ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel), options = {}) {
387
+ const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category, options);
309
388
  const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
310
389
  const winner = entries[0];
311
390
  const warnings = [];
@@ -329,7 +408,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
329
408
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
330
409
  warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
331
410
  for (const category of merchant.ambiguity.mayCodeAs) {
332
- const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
411
+ const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
333
412
  const alt = alts[0];
334
413
  if (!alt || !winner)
335
414
  continue;
@@ -356,7 +435,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
356
435
  ? resolveMerchantById(catalog, merchant.merchantId, 'online')
357
436
  : null;
358
437
  const alt = online
359
- ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
438
+ ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
360
439
  : undefined;
361
440
  if (alt)
362
441
  warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
@@ -381,8 +460,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
381
460
  const via = resolveMerchantById(catalog, id, purchase.channel);
382
461
  if (!via || !winner)
383
462
  continue;
384
- const alt = rankEntries(catalog, wallet, purchase, via, via.category)
385
- .entries[0];
463
+ const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
386
464
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
387
465
  notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
388
466
  }
@@ -418,8 +496,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
418
496
  const costHere = ties || top.earned === null || first.earned === null
419
497
  ? null
420
498
  : roundCents(first.earned +
421
- (first.milestoneBonus?.dollars ?? 0) -
422
- (top.earned + (top.milestoneBonus?.dollars ?? 0)));
499
+ (first.milestoneBonus?.dollars ?? 0) +
500
+ (first.credit?.dollars ?? 0) -
501
+ (top.earned +
502
+ (top.milestoneBonus?.dollars ?? 0) +
503
+ (top.credit?.dollars ?? 0)));
423
504
  bonusResult = {
424
505
  cardId: top.cardId,
425
506
  checkoutLabel: top.checkoutLabel,
@@ -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
@@ -29,6 +30,7 @@ export function capBooking(catalog, wallet, purchase, merchant = resolveMerchant
29
30
  capKey: cap.key,
30
31
  ruleId: rule.id,
31
32
  period: cap.period,
33
+ periodKind: cap.cap.period,
32
34
  capAmount: cap.cap.amount,
33
35
  before,
34
36
  after: roundCents(before + purchase.amount),
@@ -90,6 +92,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
90
92
  cardId: card.id,
91
93
  milestoneId: p.milestone.id,
92
94
  period: p.period,
95
+ periodKind: p.milestone.period,
93
96
  spend: p.milestone.spend,
94
97
  before: p.spent,
95
98
  after: roundCents(Math.max(0, p.spent + purchase.amount)),
@@ -110,14 +113,53 @@ export function applyMilestoneBooking(wallet, b) {
110
113
  : c),
111
114
  };
112
115
  }
116
+ // The part of an enrolled merchant credit this purchase uses. None when the
117
+ // card has no credit here, it isn't enrolled, or it's used up.
118
+ export function creditBooking(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
119
+ const held = wallet.cards.find((c) => c.id === purchase.cardId);
120
+ const card = catalog.cards.find((c) => c.id === purchase.cardId);
121
+ if (!held || !card)
122
+ return null;
123
+ const state = creditAt(catalog, card, held, merchant, purchase.date);
124
+ const back = state ? creditDollars(state, purchase.amount) : null;
125
+ if (!state || state.key === null || back === null || back <= 0)
126
+ return null;
127
+ return {
128
+ cardId: card.id,
129
+ creditId: state.credit.id,
130
+ name: state.name,
131
+ period: state.key,
132
+ periodKind: state.period,
133
+ amount: state.amount,
134
+ before: state.used,
135
+ after: roundCents(state.used + back),
136
+ };
137
+ }
138
+ export function applyCreditBooking(wallet, b) {
139
+ return {
140
+ ...wallet,
141
+ cards: wallet.cards.map((c) => c.id === b.cardId
142
+ ? {
143
+ ...c,
144
+ credit_usage: {
145
+ ...c.credit_usage,
146
+ [b.creditId]: { period: b.period, used: b.after },
147
+ },
148
+ }
149
+ : c),
150
+ };
151
+ }
113
152
  export function bookPurchase(catalog, wallet, purchase, merchant) {
114
153
  const cap = capBooking(catalog, wallet, purchase, merchant);
115
154
  const bonus = bonusBooking(catalog, wallet, purchase);
116
155
  const milestones = milestoneBookings(catalog, wallet, purchase);
156
+ const credit = creditBooking(catalog, wallet, purchase, merchant);
117
157
  let next = cap ? applyBooking(wallet, cap) : wallet;
118
158
  if (bonus)
119
159
  next = applyBonusBooking(next, bonus);
120
160
  for (const m of milestones)
121
161
  next = applyMilestoneBooking(next, m);
122
- return { wallet: next, cap, bonus, milestones };
162
+ if (credit)
163
+ next = applyCreditBooking(next, credit);
164
+ return { wallet: next, cap, bonus, milestones, credit };
123
165
  }
@@ -14,3 +14,6 @@ export function rotationGap(card, date) {
14
14
  }
15
15
  return null;
16
16
  }
17
+ // What answers say about a gap: "Freedom Flex's rotating categories for
18
+ // 2027-Q2 aren't in earnrate's data yet. ..."
19
+ export const rotationGapText = (name, gap) => `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
@@ -1,5 +1,5 @@
1
1
  import { findMerchant } from "../merchants/resolve.js";
2
- import { categoryPhrase, choiceName, listed, 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.
@@ -29,7 +29,9 @@ export function ruleParts(catalog, rule) {
29
29
  return [
30
30
  rule.geography === 'foreign_only'
31
31
  ? 'purchases outside the U.S.'
32
- : 'everything else',
32
+ : rule.requires?.payment !== undefined
33
+ ? 'purchases'
34
+ : 'everything else',
33
35
  ];
34
36
  if (rule.match.choice !== undefined)
35
37
  return [`your ${choiceName(rule.match.choice)} picks`];
@@ -55,5 +57,28 @@ export function ruleParts(catalog, rule) {
55
57
  return parts.length > 0 ? parts : [words(rule.id)];
56
58
  }
57
59
  export function ruleWhat(catalog, rule) {
58
- return listed(ruleParts(catalog, rule));
60
+ const payment = rule.requires?.payment;
61
+ const what = listed(ruleParts(catalog, rule));
62
+ return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
59
63
  }
64
+ // "double every rate for the first year", "an extra 3% cash back on your 3%
65
+ // picks for the first year".
66
+ export function introWhat(catalog, card, intro) {
67
+ const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
68
+ const on = intro.rules === undefined
69
+ ? 'every rate'
70
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
71
+ const change = intro.multiply === undefined
72
+ ? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
73
+ : intro.multiply === 2
74
+ ? `double ${on}`
75
+ : `${num(intro.multiply)} times ${on}`;
76
+ return `${change} for the ${introPeriod(intro.days)}`;
77
+ }
78
+ // What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
79
+ export const creditName = (catalog, credit) => listed([
80
+ ...(credit.match.merchants ?? []).map((m) => merchantName(catalog, m)),
81
+ ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
82
+ ]);
83
+ // "at Lululemon", "at restaurants", "on travel": a category's own preposition.
84
+ export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ? 'on' : 'at'} ${creditName(catalog, credit)}`;
@@ -2,8 +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 { 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 } from "./text.js";
7
8
  // Rules that start within this many days are worth activating now.
8
9
  const ACTIVATION_LOOKAHEAD_DAYS = 45;
9
10
  const searchWords = (s) => s
@@ -105,6 +106,22 @@ export function cardInfo(catalog, card, wallet, today) {
105
106
  rewards: m.rewards ?? [],
106
107
  text: milestoneWhat(m).replace(/^the /, ''),
107
108
  })),
109
+ introRates: card.intro_rates.map((i) => ({
110
+ id: i.id,
111
+ days: i.days,
112
+ text: introWhat(catalog, card, i),
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
+ }),
108
125
  };
109
126
  }
110
127
  // Every word of the query has to match a word of the card's id, name or
@@ -180,7 +197,7 @@ export function setupNotes(catalog, wallet, held, today) {
180
197
  if (gap !== null) {
181
198
  notes.push({
182
199
  kind: 'rotation',
183
- 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),
184
201
  });
185
202
  }
186
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 } 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,6 +155,9 @@ 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 = [
@@ -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) {
@@ -63,6 +63,15 @@ export function categoryPhrase(catalog, id) {
63
63
  ? name
64
64
  : name.charAt(0).toLowerCase() + name.slice(1);
65
65
  }
66
+ // A category bought as a service reads "on" (streaming services); its own
67
+ // preposition or a parent's says so. Everything else reads "at".
68
+ export function categoryReadsOn(catalog, id) {
69
+ const category = catalog.categories.find((c) => c.id === id);
70
+ if (category?.preposition === 'on')
71
+ return true;
72
+ const dot = id.lastIndexOf('.');
73
+ return dot !== -1 && categoryReadsOn(catalog, id.slice(0, dot));
74
+ }
66
75
  export function earnText(catalog, rate, currency) {
67
76
  return currency === 'cash'
68
77
  ? `${pct(rate)} cash back`
@@ -92,6 +101,32 @@ export const NETWORK_NAMES = {
92
101
  discover: 'Discover',
93
102
  store: 'store card',
94
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
+ }
95
130
  // A name with its own "and" ("drugstores and pharmacies") would run into the
96
131
  // list's: those lists use commas only, with those names last.
97
132
  export const listed = (parts, joiner = 'and') => {
@@ -122,6 +157,9 @@ export const CAP_PERIODS = {
122
157
  calendar_year: 'a calendar year',
123
158
  cardmember_year: 'a cardmember year',
124
159
  };
160
+ // "for 2026-Q4", or "this cardmember year": a cardmember year's key is the
161
+ // year it started, which reads as the calendar year.
162
+ export const periodPhrase = (kind, key) => kind === 'cardmember_year' ? 'this cardmember year' : `for ${key}`;
125
163
  export const CAP_ADJECTIVES = {
126
164
  month: 'monthly',
127
165
  quarter: 'quarterly',
@@ -139,3 +177,72 @@ export const PICK_PERIODS = {
139
177
  calendar_year: 'each year',
140
178
  until_changed: 'until you change it',
141
179
  };
180
+ // Intro rates (a card's first 365 days): "first year", "first 90 days".
181
+ export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
182
+ export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
183
+ // Asking for an opening date: "if it opened in the last year".
184
+ export const introSince = (days) => days === 365 ? 'in the last year' : `in the last ${String(days)} days`;
185
+ // "6% cash back in your first year, through Mar 3, 2027; 3% cash back after
186
+ // that."
187
+ export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
188
+ // For a card whose opening date isn't known: it's never assumed to be new.
189
+ 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)}.`;
190
+ export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
191
+ // Merchant statement credits. Always dollars, never a percentage.
192
+ export const CREDIT_PERIODS = {
193
+ month: 'each month',
194
+ quarter: 'each quarter',
195
+ half_year: 'each half year',
196
+ calendar_year: 'each calendar year',
197
+ cardmember_year: 'each cardmember year',
198
+ };
199
+ export const CREDIT_THIS_PERIOD = {
200
+ month: 'this month',
201
+ quarter: 'this quarter',
202
+ half_year: 'this half year',
203
+ calendar_year: 'this year',
204
+ cardmember_year: 'this cardmember year',
205
+ };
206
+ export const ENROLL_FIRST = 'enroll first';
207
+ // The group of cards with a credit at the store, shown before the ranking.
208
+ export const CREDIT_GROUP = 'With a store credit';
209
+ // "$75 back each quarter"
210
+ export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
211
+ // "$75 back each quarter, then 1x Amex Membership Rewards"
212
+ export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
213
+ // "$35 left this quarter"
214
+ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
215
+ // A line for an answer that uses the card's credit, or says why it doesn't.
216
+ export function creditAnswerText(c, dollars) {
217
+ const credit = `${c.name} credit (${creditBack(c)})`;
218
+ if (!c.enrolled)
219
+ return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
220
+ if (c.left === null)
221
+ return `Has a ${credit} here; give the card's anniversary to track it.`;
222
+ if (c.left <= 0)
223
+ return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
224
+ if (dollars === null)
225
+ return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
226
+ return dollars < c.left
227
+ ? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
228
+ : `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
229
+ }
230
+ // One short line beside a ranked card: "+ $35 back from its Lululemon
231
+ // credit", "Lululemon credit: $35 left this quarter".
232
+ export function creditShort(c, dollars) {
233
+ if (!c.enrolled)
234
+ return `${c.name} credit: ${creditBack(c)}, ${ENROLL_FIRST}`;
235
+ if (c.left === null)
236
+ return `${c.name} credit: ${creditBack(c)}`;
237
+ if (c.left <= 0)
238
+ return `${c.name} credit used up ${CREDIT_THIS_PERIOD[c.period]}`;
239
+ if (dollars === null || dollars <= 0)
240
+ return `${c.name} credit: ${creditLeft(c.left, c.period)}`;
241
+ return `+ ${money(dollars)} back from its ${c.name} credit`;
242
+ }
243
+ // For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
244
+ // 31, 2026: $35 left."
245
+ 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.`;
246
+ export const enrollCreditText = (card, name) => `Enroll in ${card}'s ${name} credit with the issuer, then mark it enrolled.`;
247
+ // Simulations, the cheat sheet and welcome-offer comparisons.
248
+ export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';