earnrate 0.2.12 → 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 (95) hide show
  1. package/README.md +20 -7
  2. package/data/CHANGELOG.md +181 -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 +27 -1
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
  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-platinum.yaml +110 -0
  17. package/data/cards/apple-card.yaml +76 -0
  18. package/data/cards/bilt-obsidian.yaml +12 -2
  19. package/data/cards/bilt-palladium.yaml +12 -2
  20. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  21. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  22. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  23. package/data/cards/capital-one-venture-x.yaml +30 -2
  24. package/data/cards/chase-aeroplan.yaml +14 -0
  25. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  26. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  27. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  28. package/data/cards/chase-ink-business-cash.yaml +13 -0
  29. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  30. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  31. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  32. package/data/cards/chase-united-business.yaml +32 -0
  33. package/data/cards/chase-united-club-business.yaml +40 -0
  34. package/data/cards/chase-united-club.yaml +40 -0
  35. package/data/cards/chase-united-explorer.yaml +32 -0
  36. package/data/cards/chase-united-quest.yaml +40 -0
  37. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  38. package/data/cards/citi-strata-elite.yaml +20 -1
  39. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  40. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  41. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  42. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  43. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  44. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  45. package/data/cards/usbank-altitude-connect.yaml +8 -2
  46. package/data/cards/usbank-altitude-go.yaml +8 -2
  47. package/data/cards/usbank-smartly.yaml +107 -0
  48. package/data/cards/venmo-credit-card.yaml +151 -0
  49. package/data/cards/verizon-visa-card.yaml +122 -0
  50. package/data/currencies.yaml +14 -0
  51. package/data/issuers.yaml +22 -0
  52. package/data/memberships.yaml +35 -0
  53. package/data/merchants/overrides.yaml +448 -0
  54. package/data/merchants/seed.yaml +1082 -65
  55. package/dist/cli/args.js +18 -0
  56. package/dist/cli/explore.js +10 -1
  57. package/dist/cli/run.js +4 -1
  58. package/dist/cli/wallet.js +30 -3
  59. package/dist/data/file-store.js +5 -2
  60. package/dist/data/integrity.js +22 -0
  61. package/dist/data/store.js +4 -1
  62. package/dist/data/wallet-edit.js +25 -6
  63. package/dist/engine/cheat-sheet.js +17 -5
  64. package/dist/engine/checklist.js +25 -4
  65. package/dist/engine/credits.js +65 -0
  66. package/dist/engine/evaluate.js +38 -11
  67. package/dist/engine/explore.js +70 -7
  68. package/dist/engine/offers.js +2 -1
  69. package/dist/engine/periods.js +10 -3
  70. package/dist/engine/recommend.js +72 -20
  71. package/dist/engine/record.js +41 -1
  72. package/dist/engine/rotation.js +3 -0
  73. package/dist/engine/rule-text.js +14 -3
  74. package/dist/engine/setup.js +15 -4
  75. package/dist/engine/simulate.js +10 -1
  76. package/dist/engine/text.js +84 -0
  77. package/dist/engine/wallet-change.js +37 -1
  78. package/dist/engine/wallet-summary.js +32 -4
  79. package/dist/format/card-text.js +4 -3
  80. package/dist/format/cheat-sheet.js +2 -0
  81. package/dist/format/explore.js +20 -4
  82. package/dist/format/recommendation.js +6 -2
  83. package/dist/format/wallet.js +6 -2
  84. package/dist/import/analyze.js +7 -2
  85. package/dist/mcp/inputs.js +10 -0
  86. package/dist/mcp/prompts.js +9 -1
  87. package/dist/mcp/schemas.js +70 -1
  88. package/dist/mcp/tools/answers.js +9 -4
  89. package/dist/mcp/tools/wallet.js +15 -7
  90. package/dist/merchants/resolve.js +8 -0
  91. package/dist/schema/card.js +37 -3
  92. package/dist/schema/common.js +1 -1
  93. package/dist/schema/merchant.js +3 -0
  94. package/dist/schema/wallet.js +8 -0
  95. package/package.json +1 -1
@@ -1,12 +1,14 @@
1
1
  import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
2
- import { evaluateCard, purchaseContext } from "./evaluate.js";
2
+ import { creditCovers, creditPerYear } from "./credits.js";
3
+ import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
4
+ import { creditName } from "./rule-text.js";
3
5
  import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
4
6
  import { choicePeriod, periodKey } from "./periods.js";
5
7
  import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
6
8
  import { rotationGap } from "./rotation.js";
7
9
  import { membershipsUsed } from "./setup.js";
8
10
  import { optionName } from "./rule-text.js";
9
- import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
11
+ import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
10
12
  import { cardValuation } from "./valuation.js";
11
13
  import { EPSILON } from "./compare.js";
12
14
  export const ALL_PERSONAL_CARDS = {
@@ -163,11 +165,13 @@ export function exploreCards(catalog, query, options, resolved) {
163
165
  channel: query.channel,
164
166
  date: query.date,
165
167
  country: query.country ?? 'US',
168
+ payment: query.payment ?? null,
166
169
  };
167
170
  const ranked = [];
171
+ const ifPaid = new Map();
168
172
  const excluded = [];
169
173
  // A Costco purchase means a Costco member: no membership to point out.
170
- const paid = paidMemberships(catalog, {
174
+ const members = paidMemberships(catalog, {
171
175
  memberships: options.memberships,
172
176
  merchantIds: [merchant.merchantId],
173
177
  });
@@ -177,7 +181,9 @@ export function exploreCards(catalog, query, options, resolved) {
177
181
  const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
178
182
  const held = idealHolding(card, query.date, picks);
179
183
  const wallet = walletOf(held, options);
180
- const rec = recommend(catalog, wallet, purchase, merchant);
184
+ const rec = recommend(catalog, wallet, purchase, merchant, {
185
+ credits: false,
186
+ });
181
187
  const top = rec.ranked[0];
182
188
  if (!top) {
183
189
  excluded.push(...rec.excluded);
@@ -185,6 +191,16 @@ export function exploreCards(catalog, query, options, resolved) {
185
191
  }
186
192
  const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
187
193
  const gap = rotationGap(card, query.date);
194
+ const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
195
+ if (paid)
196
+ ifPaid.set(card.id, {
197
+ payment: paid.payment,
198
+ earn: earnText(catalog, paid.nominalRate, top.currency),
199
+ standard: earnText(catalog, top.rate, top.currency),
200
+ returnPct: paid.rate * top.centsPerUnit - (top.foreignFee ?? 0),
201
+ });
202
+ const payment = card.rules.find((r) => r.id === top.matchedRule)?.requires
203
+ ?.payment;
188
204
  const evidenceRule = top.matchedRule ?? 'base_rate';
189
205
  const evidence = card.evidence.find((e) => e.rule === evidenceRule);
190
206
  ranked.push({
@@ -203,7 +219,8 @@ export function exploreCards(catalog, query, options, resolved) {
203
219
  requirements,
204
220
  conditions: [
205
221
  ...conditionsText(catalog, card, requirements),
206
- ...membershipNeeded(catalog, card, paid),
222
+ ...(payment === undefined ? [] : [`Paid ${PAYMENT_PHRASES[payment]}.`]),
223
+ ...membershipNeeded(catalog, card, members),
207
224
  ],
208
225
  evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
209
226
  withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
@@ -212,13 +229,48 @@ export function exploreCards(catalog, query, options, resolved) {
212
229
  ranked.sort(compareExplored);
213
230
  return {
214
231
  merchant,
232
+ credits: creditPicks(catalog, merchant, ranked),
215
233
  ranked,
216
234
  excluded,
217
- assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
235
+ assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
218
236
  warnings: exploreWarnings(catalog, merchant),
219
237
  };
220
238
  }
221
- function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
239
+ function creditPicks(catalog, merchant, ranked) {
240
+ const picks = ranked.flatMap((entry) => {
241
+ const card = catalog.cards.find((c) => c.id === entry.cardId);
242
+ return ((card?.credits ?? [])
243
+ // A credit for a whole category (every restaurant) isn't a store's.
244
+ .filter((c) => c.match.merchants !== undefined &&
245
+ creditCovers(catalog, c, merchant))
246
+ .map((credit) => {
247
+ const earn = earnText(catalog, entry.rate, entry.currency);
248
+ const evidence = card?.evidence.find((e) => e.rule === credit.id);
249
+ return {
250
+ cardId: entry.cardId,
251
+ cardName: entry.cardName,
252
+ issuer: entry.issuer,
253
+ network: entry.network,
254
+ annualFee: entry.annualFee,
255
+ firstYearFee: entry.firstYearFee,
256
+ creditId: credit.id,
257
+ name: creditName(catalog, credit),
258
+ amount: credit.amount,
259
+ period: credit.period,
260
+ enroll: credit.enroll === true,
261
+ note: credit.note ?? null,
262
+ back: creditBack(credit),
263
+ earn,
264
+ text: creditThen(credit, earn),
265
+ evidence: evidence
266
+ ? { quote: evidence.quote, url: evidence.url }
267
+ : null,
268
+ };
269
+ }));
270
+ });
271
+ return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
272
+ }
273
+ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
222
274
  const assumptions = [];
223
275
  if (merchant.source === 'category') {
224
276
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
@@ -235,6 +287,17 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
235
287
  assumptions.push(values);
236
288
  if (isForeign(purchase))
237
289
  assumptions.push(FOREIGN_ASSUMPTION);
290
+ if (purchase.payment)
291
+ assumptions.push(paidText(purchase.payment));
292
+ const top = ranked[0];
293
+ for (const entry of ranked) {
294
+ const paid = ifPaid.get(entry.cardId);
295
+ if (!paid || !top)
296
+ continue;
297
+ if (entry !== top && paid.returnPct <= top.effectiveReturnPct + EPSILON)
298
+ continue;
299
+ assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
300
+ }
238
301
  return assumptions;
239
302
  }
240
303
  function exploreWarnings(catalog, merchant) {
@@ -2,7 +2,7 @@ import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
4
  import { inEffect } from "./periods.js";
5
- import { money, roundCents } from "./text.js";
5
+ import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
6
6
  import { cardValuation } from "./valuation.js";
7
7
  function ineligibleText(catalog, wallet, notWith) {
8
8
  const held = wallet.cards
@@ -83,6 +83,7 @@ export function compareOffers(catalog, wallet, filters, today) {
83
83
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
84
84
  `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.`,
85
85
  "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.",
86
+ CREDITS_LEFT_OUT,
86
87
  ];
87
88
  return { offers, withoutOffer, notes };
88
89
  }
@@ -1,6 +1,7 @@
1
- // Keys match the wallet's PeriodKey format: "2026" or "2026-Q4". A cardmember
2
- // year is keyed by the calendar year it started in. Returns null when a
3
- // cardmember year is asked for without an anniversary (MM-DD).
1
+ // Keys match the wallet's PeriodKey format: "2026", "2026-Q4" or "2026-H2"
2
+ // (July to December). A cardmember year is keyed by the calendar year it
3
+ // started in. Returns null when a cardmember year is asked for without an
4
+ // anniversary (MM-DD).
4
5
  export function periodKey(period, date, anniversary) {
5
6
  const year = Number(date.slice(0, 4));
6
7
  const month = Number(date.slice(5, 7));
@@ -11,6 +12,8 @@ export function periodKey(period, date, anniversary) {
11
12
  return String(year);
12
13
  case 'quarter':
13
14
  return `${String(year)}-Q${String(Math.floor((month - 1) / 3) + 1)}`;
15
+ case 'half_year':
16
+ return `${String(year)}-H${month <= 6 ? '1' : '2'}`;
14
17
  case 'cardmember_year':
15
18
  if (anniversary === null)
16
19
  return null;
@@ -57,6 +60,10 @@ export function periodEnd(period, key, anniversary) {
57
60
  const q = /^(\d{4})-Q([1-4])$/.exec(key);
58
61
  return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
59
62
  }
63
+ case 'half_year': {
64
+ const h = /^(\d{4})-H([12])$/.exec(key);
65
+ return h ? utcDay(Number(h[1]), Number(h[2]) * 6 + 1, 0) : null;
66
+ }
60
67
  case 'calendar_year':
61
68
  return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
62
69
  case 'cardmember_year': {
@@ -1,14 +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
4
  import { openedOn } from "./intro.js";
5
5
  import { membershipName } from "./memberships.js";
6
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
7
7
  import { daysBetween } from "./periods.js";
8
+ import { creditAt, creditDollars } from "./credits.js";
8
9
  import { ruleWhat } from "./rule-text.js";
9
10
  import { rotationGap } from "./rotation.js";
10
11
  import { EPSILON, isClose } from "./compare.js";
11
- import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, 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";
12
13
  import { cardValuation } from "./valuation.js";
13
14
  export const STALE_AFTER_DAYS = 180;
14
15
  // The engine only tells the U.S. from everywhere else.
@@ -42,7 +43,7 @@ function compareEntries(a, b) {
42
43
  return aCapped ? 1 : -1;
43
44
  return a.card.id.localeCompare(b.card.id);
44
45
  }
45
- function rankEntries(catalog, wallet, purchase, merchant, category) {
46
+ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
46
47
  const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
47
48
  const entries = [];
48
49
  const excluded = [];
@@ -92,6 +93,14 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
92
93
  days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
93
94
  }
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;
95
104
  entries.push({
96
105
  card,
97
106
  held,
@@ -100,10 +109,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
100
109
  milestones,
101
110
  perDollar,
102
111
  fee,
103
- effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
112
+ credit,
113
+ creditDollars: back,
114
+ creditPct,
115
+ effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
104
116
  rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
105
- upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
106
- ifNew,
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,
107
131
  });
108
132
  }
109
133
  entries.sort(compareEntries);
@@ -133,6 +157,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
133
157
  }
134
158
  for (const hit of entry.milestones)
135
159
  lines.push(reachedText(card, hit, valuation.centsPerUnit));
160
+ if (entry.credit)
161
+ lines.push(creditAnswerText(entry.credit, entry.creditDollars));
136
162
  if (valuation.pooledFrom !== null) {
137
163
  const partners = card.pools_into?.when_wallet_has ?? [];
138
164
  const heldPartners = wallet.cards
@@ -218,6 +244,16 @@ function toRanked(catalog, wallet, entry, purchase) {
218
244
  entry.valuation.centsPerUnit) /
219
245
  100),
220
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
+ },
221
257
  capped: evaluation.best.cap !== null,
222
258
  reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
223
259
  warnings: warningsFor(catalog, entry, purchase),
@@ -262,15 +298,16 @@ function sensitivity(catalog, entries) {
262
298
  continue;
263
299
  const wRate = winner.perDollar;
264
300
  const oRate = other.perDollar;
265
- // 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.
266
303
  if (w.range && wRate > 0) {
267
- const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
304
+ const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
268
305
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
269
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)}¢).`);
270
307
  }
271
308
  }
272
309
  if (o.range && oRate > 0) {
273
- const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
310
+ const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
274
311
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
275
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)}¢).`);
276
313
  }
@@ -292,8 +329,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
292
329
  const naming = entries.flatMap((e) => {
293
330
  const rule = e.evaluation.best.rule;
294
331
  return rule &&
295
- merchant.merchantId !== null &&
296
- (rule.match.merchants ?? []).includes(merchant.merchantId)
332
+ merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
297
333
  ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
298
334
  : [];
299
335
  });
@@ -310,6 +346,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
310
346
  if (purchase.channel === null && merchant.channel === 'in_store') {
311
347
  assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
312
348
  }
349
+ if (merchant.channel === null && purchase.payment === 'paypal') {
350
+ assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
351
+ }
313
352
  if (purchase.channel === null && merchant.channel === 'online') {
314
353
  assumptions.push('Assumes an online purchase, since you gave a web address.');
315
354
  }
@@ -318,7 +357,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
318
357
  assumptions.push(values);
319
358
  if (isForeign(purchase))
320
359
  assumptions.push(FOREIGN_ASSUMPTION);
360
+ if (purchase.payment)
361
+ assumptions.push(paidText(purchase.payment));
321
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
+ }
322
372
  for (const e of entries) {
323
373
  if (!e.ifNew || !winner)
324
374
  continue;
@@ -333,8 +383,8 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
333
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.`);
334
384
  return assumptions;
335
385
  }
336
- export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
337
- 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);
338
388
  const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
339
389
  const winner = entries[0];
340
390
  const warnings = [];
@@ -358,7 +408,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
358
408
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
359
409
  warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
360
410
  for (const category of merchant.ambiguity.mayCodeAs) {
361
- const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
411
+ const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
362
412
  const alt = alts[0];
363
413
  if (!alt || !winner)
364
414
  continue;
@@ -385,7 +435,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
385
435
  ? resolveMerchantById(catalog, merchant.merchantId, 'online')
386
436
  : null;
387
437
  const alt = online
388
- ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
438
+ ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
389
439
  : undefined;
390
440
  if (alt)
391
441
  warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
@@ -410,8 +460,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
410
460
  const via = resolveMerchantById(catalog, id, purchase.channel);
411
461
  if (!via || !winner)
412
462
  continue;
413
- const alt = rankEntries(catalog, wallet, purchase, via, via.category)
414
- .entries[0];
463
+ const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
415
464
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
416
465
  notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
417
466
  }
@@ -447,8 +496,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
447
496
  const costHere = ties || top.earned === null || first.earned === null
448
497
  ? null
449
498
  : roundCents(first.earned +
450
- (first.milestoneBonus?.dollars ?? 0) -
451
- (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)));
452
504
  bonusResult = {
453
505
  cardId: top.cardId,
454
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
@@ -112,14 +113,53 @@ export function applyMilestoneBooking(wallet, b) {
112
113
  : c),
113
114
  };
114
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
+ }
115
152
  export function bookPurchase(catalog, wallet, purchase, merchant) {
116
153
  const cap = capBooking(catalog, wallet, purchase, merchant);
117
154
  const bonus = bonusBooking(catalog, wallet, purchase);
118
155
  const milestones = milestoneBookings(catalog, wallet, purchase);
156
+ const credit = creditBooking(catalog, wallet, purchase, merchant);
119
157
  let next = cap ? applyBooking(wallet, cap) : wallet;
120
158
  if (bonus)
121
159
  next = applyBonusBooking(next, bonus);
122
160
  for (const m of milestones)
123
161
  next = applyMilestoneBooking(next, m);
124
- return { wallet: next, cap, bonus, milestones };
162
+ if (credit)
163
+ next = applyCreditBooking(next, credit);
164
+ return { wallet: next, cap, bonus, milestones, credit };
125
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, 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.
@@ -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,7 +57,9 @@ 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
  }
60
64
  // "double every rate for the first year", "an extra 3% cash back on your 3%
61
65
  // picks for the first year".
@@ -71,3 +75,10 @@ export function introWhat(catalog, card, intro) {
71
75
  : `${num(intro.multiply)} times ${on}`;
72
76
  return `${change} for the ${introPeriod(intro.days)}`;
73
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,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 } 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
@@ -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 } 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) {