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
@@ -0,0 +1,65 @@
1
+ import { merchantFamily } from "../merchants/resolve.js";
2
+ import { categoryMatches } from "./evaluate.js";
3
+ import { periodEnd, periodKey } from "./periods.js";
4
+ import { creditName } from "./rule-text.js";
5
+ import { roundCents } from "./text.js";
6
+ // Only a store itself has a credit: a search for a kind of purchase
7
+ // ("clothing") never does.
8
+ // A credit naming a merchant also covers its brands (Hilton's covers Hampton).
9
+ export function creditCovers(catalog, credit, merchant) {
10
+ if (merchant.source === 'category' || merchant.source === 'none')
11
+ return false;
12
+ const named = credit.match.merchants;
13
+ if (named)
14
+ return merchantFamily(catalog, merchant.merchantId).some((id) => named.includes(id));
15
+ return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
16
+ }
17
+ // A credit that needs enrolling counts only once the wallet says it's done.
18
+ export const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
+ export function creditState(catalog, credit, held, date) {
20
+ const anniversary = held.anniversary ?? null;
21
+ const period = periodKey(credit.period, date, anniversary);
22
+ const usage = held.credit_usage?.[credit.id];
23
+ const used = period !== null && usage !== undefined && usage.period === period
24
+ ? usage.used
25
+ : 0;
26
+ return {
27
+ credit,
28
+ key: period,
29
+ name: creditName(catalog, credit),
30
+ amount: credit.amount,
31
+ period: credit.period,
32
+ enrolled: isEnrolled(credit, held),
33
+ used,
34
+ left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
35
+ until: period === null ? null : periodEnd(credit.period, period, anniversary),
36
+ };
37
+ }
38
+ export const creditStates = (catalog, card, held, date) => card.credits.map((c) => creditState(catalog, c, held, date));
39
+ export const counts = (s) => s.enrolled && s.left !== null && s.left > 0;
40
+ // The card's credit at this merchant, if any: one that counts first, then
41
+ // the one with the most left.
42
+ export function creditAt(catalog, card, held, merchant, date) {
43
+ const here = card.credits
44
+ .filter((c) => creditCovers(catalog, c, merchant))
45
+ .map((c) => creditState(catalog, c, held, date))
46
+ .sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
47
+ return here[0] ?? null;
48
+ }
49
+ // Dollars back on a purchase: null without an amount, 0 when the credit
50
+ // doesn't count (not enrolled, or used up).
51
+ export function creditDollars(s, amount) {
52
+ if (amount === null)
53
+ return null;
54
+ if (!counts(s) || amount <= 0)
55
+ return 0;
56
+ return roundCents(Math.min(amount, s.left ?? 0));
57
+ }
58
+ const PER_YEAR = {
59
+ month: 12,
60
+ quarter: 4,
61
+ half_year: 2,
62
+ calendar_year: 1,
63
+ cardmember_year: 1,
64
+ };
65
+ export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
@@ -1,16 +1,23 @@
1
+ import { merchantFamily } from "../merchants/resolve.js";
2
+ import { EPSILON } from "./compare.js";
3
+ import { introCovers, introWindows, withIntro, } from "./intro.js";
1
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);
2
7
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
8
+ const payment = purchase.payment ?? null;
3
9
  return {
4
10
  category,
5
- merchantId: merchant.merchantId,
11
+ merchantIds: merchantFamily(catalog, merchant.merchantId),
6
12
  mcc: category === merchant.category && merchant.likelyMcc !== null
7
13
  ? merchant.likelyMcc
8
14
  : singleMcc(catalog, category),
9
- channel: merchant.channel,
15
+ channel: paymentChannel(merchant.channel, payment),
10
16
  country: purchase.country,
11
17
  date: purchase.date,
12
18
  amount: purchase.amount,
13
19
  memberships: new Set(wallet.memberships),
20
+ payment,
14
21
  };
15
22
  }
16
23
  // A category with one typical MCC (travel agencies are 4722) implies it, the
@@ -78,18 +85,16 @@ export function isActivated(card, held, ruleId) {
78
85
  function applicability(card, rule, ctx, held, picked) {
79
86
  const { match, exclude } = rule;
80
87
  const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
88
+ const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
81
89
  if (match.categories?.length && !inCategory(match.categories))
82
90
  return 'no';
83
- if (match.merchants?.length &&
84
- !(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
91
+ if (match.merchants?.length && !named(match.merchants))
85
92
  return 'no';
86
- }
87
93
  if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
88
94
  return 'no';
89
95
  if (match.choice !== undefined) {
90
96
  const covered = picked.get(match.choice);
91
- const merchantPicked = ctx.merchantId !== null &&
92
- (covered?.merchants.includes(ctx.merchantId) ?? false);
97
+ const merchantPicked = named(covered?.merchants);
93
98
  const onlinePicked = ctx.channel === 'online' &&
94
99
  (covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
95
100
  if (!merchantPicked &&
@@ -99,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
99
104
  }
100
105
  if (exclude?.categories && inCategory(exclude.categories))
101
106
  return 'no';
102
- if (exclude?.merchants &&
103
- ctx.merchantId !== null &&
104
- exclude.merchants.includes(ctx.merchantId)) {
107
+ if (named(exclude?.merchants))
105
108
  return 'no';
106
- }
107
109
  if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
108
110
  return 'no';
111
+ if (exclude?.payment &&
112
+ ctx.payment !== null &&
113
+ exclude.payment.includes(ctx.payment))
114
+ return 'no';
109
115
  if (rule.channel !== 'any' && rule.channel !== ctx.channel)
110
116
  return 'no';
111
117
  if (rule.geography === 'us_only' && ctx.country !== 'US')
@@ -117,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
117
123
  const membership = rule.requires?.membership;
118
124
  if (membership !== undefined && !ctx.memberships.has(membership))
119
125
  return 'no';
126
+ const payment = rule.requires?.payment;
127
+ if (payment !== undefined && ctx.payment !== payment)
128
+ return 'no';
120
129
  if (rule.requires?.activation && !isActivated(card, held, rule.id))
121
130
  return 'needs-activation';
122
131
  return 'applies';
@@ -167,10 +176,16 @@ function beats(a, aRate, b, bRate) {
167
176
  }
168
177
  export function evaluateCard(card, held, ctx) {
169
178
  const { picked, staleChoice } = pickedCategories(card, held, ctx.date);
179
+ const windows = introWindows(card, held, ctx.date);
180
+ const introFor = (ruleId) => windows.filter((w) => introCovers(w.intro, ruleId));
181
+ const rateOf = (rule) => withIntro(rule.rate, rule.id, windows);
182
+ const baseRate = withIntro(card.base_rate, null, windows);
170
183
  const base = {
171
184
  rule: null,
172
- nominalRate: card.base_rate,
173
- rate: card.base_rate,
185
+ nominalRate: baseRate,
186
+ standardRate: card.base_rate,
187
+ intro: introFor(null),
188
+ rate: baseRate,
174
189
  cap: null,
175
190
  afterCap: null,
176
191
  };
@@ -187,14 +202,19 @@ export function evaluateCard(card, held, ctx) {
187
202
  else
188
203
  applying.push(rule);
189
204
  }
190
- const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
205
+ const uncapped = Math.max(baseRate, ...applying.filter((r) => r.cap === undefined).map(rateOf));
191
206
  for (const rule of applying) {
192
207
  const cap = capState(rule, card, held, ctx.date);
193
- const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
208
+ const rate = rateOf(rule);
209
+ const afterCap = cap
210
+ ? Math.max(withIntro(cap.cap.then_rate, rule.id, windows, true), uncapped)
211
+ : null;
194
212
  const earning = {
195
213
  rule,
196
- nominalRate: rule.rate,
197
- rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
214
+ nominalRate: rate,
215
+ standardRate: rule.rate,
216
+ intro: introFor(rule.id),
217
+ rate: rateAfterCap(rate, cap, afterCap ?? rate, ctx.amount),
198
218
  cap,
199
219
  afterCap,
200
220
  };
@@ -206,7 +226,26 @@ export function evaluateCard(card, held, ctx) {
206
226
  return {
207
227
  best,
208
228
  nominal,
209
- needsActivation: blocked.filter((r) => r.rate > best.rate),
229
+ needsActivation: blocked.filter((r) => rateOf(r) > best.rate),
210
230
  staleChoice,
211
231
  };
212
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
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) {
@@ -0,0 +1,36 @@
1
+ export function addDays(date, days) {
2
+ const t = new Date(`${date}T00:00:00Z`);
3
+ t.setUTCDate(t.getUTCDate() + days);
4
+ return t.toISOString().slice(0, 10);
5
+ }
6
+ // Never assumed: a card without a date is ranked at its standard rates.
7
+ export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
8
+ // The opening day is day 1, so 365 days run through the day before the first
9
+ // anniversary.
10
+ export const introUntil = (intro, opened) => addDays(opened, intro.days - 1);
11
+ // The card's intro rates in effect on `date`.
12
+ export function introWindows(card, held, date) {
13
+ const opened = openedOn(held);
14
+ if (opened === null || date < opened)
15
+ return [];
16
+ return card.intro_rates.flatMap((intro) => {
17
+ const until = introUntil(intro, opened);
18
+ return date <= until ? [{ intro, until }] : [];
19
+ });
20
+ }
21
+ // Whether an intro rate changes this rule's rate (null: the base rate). Past
22
+ // a cap only a whole-card intro applies.
23
+ export const introCovers = (intro, ruleId, pastCap = false) => intro.rules === undefined ||
24
+ (!pastCap && ruleId !== null && intro.rules.includes(ruleId));
25
+ export function withIntro(rate, ruleId, windows, pastCap = false) {
26
+ let out = rate;
27
+ for (const { intro } of windows) {
28
+ if (!introCovers(intro, ruleId, pastCap))
29
+ continue;
30
+ out =
31
+ intro.multiply !== undefined
32
+ ? out * intro.multiply
33
+ : out + (intro.add ?? 0);
34
+ }
35
+ return out;
36
+ }
@@ -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': {