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,17 +1,23 @@
1
+ import { merchantFamily } from "../merchants/resolve.js";
2
+ import { EPSILON } from "./compare.js";
1
3
  import { introCovers, introWindows, withIntro, } from "./intro.js";
2
4
  import { inEffect, periodKey } from "./periods.js";
5
+ // PayPal checkout happens online, so paying through it says the channel.
6
+ export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
3
7
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
8
+ const payment = purchase.payment ?? null;
4
9
  return {
5
10
  category,
6
- merchantId: merchant.merchantId,
11
+ merchantIds: merchantFamily(catalog, merchant.merchantId),
7
12
  mcc: category === merchant.category && merchant.likelyMcc !== null
8
13
  ? merchant.likelyMcc
9
14
  : singleMcc(catalog, category),
10
- channel: merchant.channel,
15
+ channel: paymentChannel(merchant.channel, payment),
11
16
  country: purchase.country,
12
17
  date: purchase.date,
13
18
  amount: purchase.amount,
14
19
  memberships: new Set(wallet.memberships),
20
+ payment,
15
21
  };
16
22
  }
17
23
  // A category with one typical MCC (travel agencies are 4722) implies it, the
@@ -79,18 +85,16 @@ export function isActivated(card, held, ruleId) {
79
85
  function applicability(card, rule, ctx, held, picked) {
80
86
  const { match, exclude } = rule;
81
87
  const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
88
+ const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
82
89
  if (match.categories?.length && !inCategory(match.categories))
83
90
  return 'no';
84
- if (match.merchants?.length &&
85
- !(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
91
+ if (match.merchants?.length && !named(match.merchants))
86
92
  return 'no';
87
- }
88
93
  if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
89
94
  return 'no';
90
95
  if (match.choice !== undefined) {
91
96
  const covered = picked.get(match.choice);
92
- const merchantPicked = ctx.merchantId !== null &&
93
- (covered?.merchants.includes(ctx.merchantId) ?? false);
97
+ const merchantPicked = named(covered?.merchants);
94
98
  const onlinePicked = ctx.channel === 'online' &&
95
99
  (covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
96
100
  if (!merchantPicked &&
@@ -100,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
100
104
  }
101
105
  if (exclude?.categories && inCategory(exclude.categories))
102
106
  return 'no';
103
- if (exclude?.merchants &&
104
- ctx.merchantId !== null &&
105
- exclude.merchants.includes(ctx.merchantId)) {
107
+ if (named(exclude?.merchants))
106
108
  return 'no';
107
- }
108
109
  if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
109
110
  return 'no';
111
+ if (exclude?.payment &&
112
+ ctx.payment !== null &&
113
+ exclude.payment.includes(ctx.payment))
114
+ return 'no';
110
115
  if (rule.channel !== 'any' && rule.channel !== ctx.channel)
111
116
  return 'no';
112
117
  if (rule.geography === 'us_only' && ctx.country !== 'US')
@@ -118,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
118
123
  const membership = rule.requires?.membership;
119
124
  if (membership !== undefined && !ctx.memberships.has(membership))
120
125
  return 'no';
126
+ const payment = rule.requires?.payment;
127
+ if (payment !== undefined && ctx.payment !== payment)
128
+ return 'no';
121
129
  if (rule.requires?.activation && !isActivated(card, held, rule.id))
122
130
  return 'needs-activation';
123
131
  return 'applies';
@@ -222,3 +230,22 @@ export function evaluateCard(card, held, ctx) {
222
230
  staleChoice,
223
231
  };
224
232
  }
233
+ // The way of paying (one a rule on the card requires) that would earn the
234
+ // most on this purchase, when that beats `rate`. Only while the purchase
235
+ // doesn't say how it's paid.
236
+ export function paymentUpgrade(card, held, ctx, rate) {
237
+ if (ctx.payment !== null)
238
+ return null;
239
+ let best = null;
240
+ const methods = new Set(card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]));
241
+ for (const payment of methods) {
242
+ const paid = evaluateCard(card, held, {
243
+ ...ctx,
244
+ payment,
245
+ channel: paymentChannel(ctx.channel, payment),
246
+ }).best;
247
+ if (paid.rate > (best?.rate ?? rate) + EPSILON)
248
+ best = { payment, rate: paid.rate, nominalRate: paid.nominalRate };
249
+ }
250
+ return best;
251
+ }
@@ -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
- import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
7
+ import { FOREIGN_ASSUMPTION, foreignFeeFor, 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 = {
@@ -129,7 +131,7 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
129
131
  memberships: [...options.memberships, membership],
130
132
  });
131
133
  const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
132
- const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
134
+ const fee = foreignFeeFor(card, purchase);
133
135
  const pct = evaluateCard(card, held, ctx).best.rate *
134
136
  cardValuation(catalog, wallet, card).centsPerUnit -
135
137
  fee;
@@ -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,19 +1,26 @@
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";
4
- import { openedOn } from "./intro.js";
3
+ import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { introWindows, openedOn, withIntro } 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, possessive, } 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.
15
16
  export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
16
17
  export const isForeign = (purchase) => purchase.country !== 'US';
18
+ // A fee that isn't on file counts as the usual 3%, so a card can't win
19
+ // abroad on a guess that it charges none.
20
+ export const ASSUMED_FOREIGN_FEE = 3;
21
+ export const foreignFeeFor = (card, purchase) => isForeign(purchase)
22
+ ? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
23
+ : 0;
17
24
  // Any purchase outside the U.S., when no country is named: ZZ is a code ISO
18
25
  // leaves unassigned, and nothing prints it.
19
26
  export const ABROAD = 'ZZ';
@@ -42,7 +49,7 @@ function compareEntries(a, b) {
42
49
  return aCapped ? 1 : -1;
43
50
  return a.card.id.localeCompare(b.card.id);
44
51
  }
45
- function rankEntries(catalog, wallet, purchase, merchant, category) {
52
+ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
46
53
  const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
47
54
  const entries = [];
48
55
  const excluded = [];
@@ -78,8 +85,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
78
85
  const bonusPoints = milestonePoints(milestones);
79
86
  const perDollar = evaluation.best.rate +
80
87
  (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
81
- // An unknown fee isn't guessed: it's warned about instead.
82
- const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
88
+ const fee = foreignFeeFor(card, purchase);
83
89
  const fresh = card.intro_rates.length > 0 && openedOn(held) === null
84
90
  ? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
85
91
  : null;
@@ -92,6 +98,14 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
92
98
  days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
93
99
  }
94
100
  : null;
101
+ const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
102
+ const credit = options.credits === false
103
+ ? null
104
+ : creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
105
+ const back = credit ? creditDollars(credit, purchase.amount) : null;
106
+ const creditPct = back !== null && back > 0 && purchase.amount
107
+ ? (back / purchase.amount) * 100
108
+ : 0;
95
109
  entries.push({
96
110
  card,
97
111
  held,
@@ -100,10 +114,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
100
114
  milestones,
101
115
  perDollar,
102
116
  fee,
103
- effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
117
+ credit,
118
+ creditDollars: back,
119
+ creditPct,
120
+ effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
104
121
  rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
105
- upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
106
- ifNew,
122
+ upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
123
+ fee +
124
+ creditPct,
125
+ ifNew: ifNew && { ...ifNew, returnPct: ifNew.returnPct + creditPct },
126
+ ifPaid: paid
127
+ ? {
128
+ payment: paid.payment,
129
+ rate: paid.nominalRate,
130
+ returnPct: (perDollar - evaluation.best.rate + paid.rate) *
131
+ valuation.centsPerUnit -
132
+ fee +
133
+ creditPct,
134
+ }
135
+ : null,
107
136
  });
108
137
  }
109
138
  entries.sort(compareEntries);
@@ -114,18 +143,19 @@ function reasoningFor(catalog, wallet, entry, amount) {
114
143
  const { best } = evaluation;
115
144
  const lines = [];
116
145
  const currency = valuation.currency;
146
+ // The standard rate first; a first-year rate on top of it is its own line.
147
+ // The intro's terms stay in search_cards, not in every answer.
148
+ const [intro] = best.intro;
149
+ const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
117
150
  if (best.rule) {
118
151
  const note = best.rule.note ? ` ${best.rule.note}` : '';
119
- 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}`);
152
+ lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
120
153
  }
121
154
  else {
122
- lines.push(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
123
- }
124
- const [intro] = best.intro;
125
- if (intro) {
126
- const note = intro.intro.note ? ` ${intro.intro.note}` : '';
127
- lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
155
+ lines.push(`${standard}, its base rate. No bonus category applies here.`);
128
156
  }
157
+ if (intro)
158
+ lines.push(introRateText(earnText(catalog, best.nominalRate, currency), standard, intro.intro.days, intro.until));
129
159
  const cap = best.cap;
130
160
  if (cap && amount !== null && amount > cap.remaining) {
131
161
  const over = amount - cap.remaining;
@@ -133,6 +163,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
133
163
  }
134
164
  for (const hit of entry.milestones)
135
165
  lines.push(reachedText(card, hit, valuation.centsPerUnit));
166
+ if (entry.credit)
167
+ lines.push(creditAnswerText(entry.credit, entry.creditDollars));
136
168
  if (valuation.pooledFrom !== null) {
137
169
  const partners = card.pools_into?.when_wallet_has ?? [];
138
170
  const heldPartners = wallet.cards
@@ -151,10 +183,10 @@ function warningsFor(catalog, entry, purchase) {
151
183
  const { card, valuation, evaluation } = entry;
152
184
  const { best, nominal } = evaluation;
153
185
  const warnings = [];
154
- if (entry.fee > 0)
155
- warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
156
186
  if (isForeign(purchase) && card.foreign_transaction_fee === null)
157
- warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
187
+ warnings.push(`Its foreign transaction fee isn't on file, so earnrate assumes the usual ${pct(entry.fee)} and it nets ${pct(entry.rateReturnPct)}. Check the issuer's terms before using it abroad.`);
188
+ else if (entry.fee > 0)
189
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
158
190
  const capLabel = (e) => e.cap
159
191
  ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
160
192
  : '';
@@ -186,12 +218,14 @@ function warningsFor(catalog, entry, purchase) {
186
218
  if (evaluation.staleChoice !== null) {
187
219
  warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
188
220
  }
221
+ const windows = introWindows(card, entry.held, purchase.date);
189
222
  for (const rule of evaluation.needsActivation) {
223
+ const rate = earnText(catalog, withIntro(rule.rate, rule.id, windows), valuation.currency);
190
224
  if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
191
- warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
225
+ warnings.push(`The bonus here (${rate}) needed activating by ${shortDate(rule.activate_by)}.`);
192
226
  continue;
193
227
  }
194
- warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
228
+ warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${rate} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
195
229
  }
196
230
  return warnings;
197
231
  }
@@ -218,6 +252,16 @@ function toRanked(catalog, wallet, entry, purchase) {
218
252
  entry.valuation.centsPerUnit) /
219
253
  100),
220
254
  },
255
+ credit: entry.credit && {
256
+ id: entry.credit.credit.id,
257
+ name: entry.credit.name,
258
+ amount: entry.credit.amount,
259
+ period: entry.credit.period,
260
+ enrolled: entry.credit.enrolled,
261
+ left: entry.credit.left,
262
+ until: entry.credit.until,
263
+ dollars: entry.creditDollars,
264
+ },
221
265
  capped: evaluation.best.cap !== null,
222
266
  reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
223
267
  warnings: warningsFor(catalog, entry, purchase),
@@ -262,15 +306,16 @@ function sensitivity(catalog, entries) {
262
306
  continue;
263
307
  const wRate = winner.perDollar;
264
308
  const oRate = other.perDollar;
265
- // Net return is rate × value − fee, so each flip point adds the fee back.
309
+ // Net return is rate × value − fee + credit, so each flip point adds the
310
+ // fee back and takes the credit off.
266
311
  if (w.range && wRate > 0) {
267
- const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
312
+ const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
268
313
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
269
314
  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
315
  }
271
316
  }
272
317
  if (o.range && oRate > 0) {
273
- const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
318
+ const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
274
319
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
275
320
  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
321
  }
@@ -292,8 +337,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
292
337
  const naming = entries.flatMap((e) => {
293
338
  const rule = e.evaluation.best.rule;
294
339
  return rule &&
295
- merchant.merchantId !== null &&
296
- (rule.match.merchants ?? []).includes(merchant.merchantId)
340
+ merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
297
341
  ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
298
342
  : [];
299
343
  });
@@ -310,6 +354,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
310
354
  if (purchase.channel === null && merchant.channel === 'in_store') {
311
355
  assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
312
356
  }
357
+ if (merchant.channel === null && purchase.payment === 'paypal') {
358
+ assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
359
+ }
313
360
  if (purchase.channel === null && merchant.channel === 'online') {
314
361
  assumptions.push('Assumes an online purchase, since you gave a web address.');
315
362
  }
@@ -318,7 +365,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
318
365
  assumptions.push(values);
319
366
  if (isForeign(purchase))
320
367
  assumptions.push(FOREIGN_ASSUMPTION);
368
+ if (purchase.payment)
369
+ assumptions.push(paidText(purchase.payment));
321
370
  const winner = entries[0];
371
+ for (const e of entries) {
372
+ if (!e.ifPaid || !winner)
373
+ continue;
374
+ if (e !== winner &&
375
+ e.ifPaid.returnPct <= winner.effectiveReturnPct + EPSILON)
376
+ continue;
377
+ const currency = e.valuation.currency;
378
+ assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
379
+ }
322
380
  for (const e of entries) {
323
381
  if (!e.ifNew || !winner)
324
382
  continue;
@@ -333,15 +391,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
333
391
  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
392
  return assumptions;
335
393
  }
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);
394
+ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel), options = {}) {
395
+ const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category, options);
338
396
  const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
339
397
  const winner = entries[0];
340
398
  const warnings = [];
341
399
  const notes = [];
342
- const charging = entries.filter((e) => e.fee > 0);
400
+ const charging = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee !== null);
401
+ const assumed = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee === null);
343
402
  if (charging.length > 0 && entries.length > 1)
344
403
  notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
404
+ if (assumed.length > 0 && entries.length > 1)
405
+ notes.push(`${listed(assumed.map(displayName))} ${assumed.length === 1 ? 'has' : 'have'} no foreign transaction fee on file, so the usual ${pct(ASSUMED_FOREIGN_FEE)} is subtracted.`);
345
406
  if (merchant.source === 'none') {
346
407
  warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
347
408
  }
@@ -358,7 +419,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
358
419
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
359
420
  warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
360
421
  for (const category of merchant.ambiguity.mayCodeAs) {
361
- const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
422
+ const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
362
423
  const alt = alts[0];
363
424
  if (!alt || !winner)
364
425
  continue;
@@ -385,7 +446,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
385
446
  ? resolveMerchantById(catalog, merchant.merchantId, 'online')
386
447
  : null;
387
448
  const alt = online
388
- ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
449
+ ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
389
450
  : undefined;
390
451
  if (alt)
391
452
  warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
@@ -403,15 +464,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
403
464
  const gap = card ? rotationGap(card, purchase.date) : null;
404
465
  if (card && gap !== null) {
405
466
  const name = held.checkout_label ?? card.name;
406
- warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
467
+ warnings.push(`${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
407
468
  }
408
469
  }
409
470
  for (const id of merchant.bookedVia) {
410
471
  const via = resolveMerchantById(catalog, id, purchase.channel);
411
472
  if (!via || !winner)
412
473
  continue;
413
- const alt = rankEntries(catalog, wallet, purchase, via, via.category)
414
- .entries[0];
474
+ const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
415
475
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
416
476
  notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
417
477
  }
@@ -447,8 +507,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
447
507
  const costHere = ties || top.earned === null || first.earned === null
448
508
  ? null
449
509
  : roundCents(first.earned +
450
- (first.milestoneBonus?.dollars ?? 0) -
451
- (top.earned + (top.milestoneBonus?.dollars ?? 0)));
510
+ (first.milestoneBonus?.dollars ?? 0) +
511
+ (first.credit?.dollars ?? 0) -
512
+ (top.earned +
513
+ (top.milestoneBonus?.dollars ?? 0) +
514
+ (top.credit?.dollars ?? 0)));
452
515
  bonusResult = {
453
516
  cardId: top.cardId,
454
517
  checkoutLabel: top.checkoutLabel,
@@ -464,7 +527,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
464
527
  const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
465
528
  notes.unshift(ties
466
529
  ? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
467
- : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
530
+ : `You're behind pace on ${possessive(displayName(bonus.entry))} welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
468
531
  ? ''
469
532
  : costHere === null
470
533
  ? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`