earnrate 0.2.16 → 0.2.17

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 (52) hide show
  1. package/README.md +14 -8
  2. package/data/CHANGELOG.md +14 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-platinum.yaml +15 -1
  5. package/data/cards/barclays-jetblue-plus.yaml +19 -1
  6. package/data/cards/chase-freedom-flex.yaml +7 -7
  7. package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
  8. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +5 -1
  10. package/data/cards/chase-ihg-one-rewards.yaml +13 -2
  11. package/data/cards/chase-ink-business-cash.yaml +5 -1
  12. package/data/cards/chase-ink-business-unlimited.yaml +5 -1
  13. package/data/cards/chase-prime-visa.yaml +3 -3
  14. package/data/cards/chase-sapphire-reserve.yaml +12 -3
  15. package/data/cards/chase-united-business.yaml +5 -1
  16. package/data/cards/chase-united-club-business.yaml +5 -1
  17. package/data/cards/chase-united-club.yaml +5 -1
  18. package/data/cards/chase-united-explorer.yaml +5 -1
  19. package/data/cards/chase-united-quest.yaml +5 -1
  20. package/data/cards/citi-strata-premier.yaml +14 -1
  21. package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
  22. package/data/merchants/overrides.yaml +3 -3
  23. package/dist/cli/args.js +13 -7
  24. package/dist/cli/run.js +4 -0
  25. package/dist/cli/wallet.js +18 -8
  26. package/dist/data/integrity.js +13 -0
  27. package/dist/data/wallet-edit.js +5 -0
  28. package/dist/engine/checklist.js +25 -5
  29. package/dist/engine/credits.js +56 -7
  30. package/dist/engine/evaluate.js +21 -1
  31. package/dist/engine/explore.js +27 -9
  32. package/dist/engine/memberships.js +21 -9
  33. package/dist/engine/milestones.js +11 -3
  34. package/dist/engine/offers.js +9 -4
  35. package/dist/engine/recommend.js +21 -2
  36. package/dist/engine/rule-text.js +20 -7
  37. package/dist/engine/setup.js +7 -6
  38. package/dist/engine/simulate.js +43 -10
  39. package/dist/engine/text.js +46 -5
  40. package/dist/engine/wallet-change.js +18 -7
  41. package/dist/engine/wallet-summary.js +25 -10
  42. package/dist/format/card-text.js +2 -10
  43. package/dist/format/explore.js +12 -8
  44. package/dist/format/offers.js +1 -1
  45. package/dist/format/wallet.js +2 -0
  46. package/dist/mcp/inputs.js +9 -1
  47. package/dist/mcp/schemas.js +23 -0
  48. package/dist/mcp/tools/account.js +1 -1
  49. package/dist/mcp/tools/wallet.js +5 -1
  50. package/dist/schema/card.js +11 -0
  51. package/dist/schema/wallet.js +6 -2
  52. package/package.json +1 -1
package/dist/cli/args.js CHANGED
@@ -96,6 +96,7 @@ export function parseCliArgs(argv) {
96
96
  cards: { type: 'string' },
97
97
  member: { type: 'string', multiple: true },
98
98
  enrolled: { type: 'string', multiple: true },
99
+ 'skipped-credits': { type: 'string', multiple: true },
99
100
  opened: { type: 'string', multiple: true },
100
101
  help: { type: 'boolean', short: 'h' },
101
102
  },
@@ -147,6 +148,9 @@ export function parseCliArgs(argv) {
147
148
  const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
148
149
  if (typeof enrolled === 'string')
149
150
  return { kind: 'error', message: enrolled };
151
+ const skipped = perCard('skipped-credits', 'amex-gold=dunkin');
152
+ if (typeof skipped === 'string')
153
+ return { kind: 'error', message: skipped };
150
154
  const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
151
155
  if (typeof opened === 'string')
152
156
  return { kind: 'error', message: opened };
@@ -166,14 +170,16 @@ export function parseCliArgs(argv) {
166
170
  llm: values.llm ?? null,
167
171
  cards,
168
172
  members: values.member ?? [],
169
- enrolled: Object.fromEntries(Object.entries(enrolled).map(([id, list]) => [
170
- id,
171
- list
172
- .split(',')
173
- .map((c) => c.trim())
174
- .filter(Boolean),
175
- ])),
173
+ enrolled: creditLists(enrolled),
174
+ skipped: creditLists(skipped),
176
175
  opened,
177
176
  },
178
177
  };
179
178
  }
179
+ const creditLists = (byCard) => Object.fromEntries(Object.entries(byCard).map(([id, list]) => [
180
+ id,
181
+ list
182
+ .split(',')
183
+ .map((c) => c.trim())
184
+ .filter(Boolean),
185
+ ]));
package/dist/cli/run.js CHANGED
@@ -73,6 +73,9 @@ Options:
73
73
  --enrolled <card>=<credits>
74
74
  For a named card, statement credits you've enrolled
75
75
  in: --enrolled amex-platinum=lululemon. Repeat per card.
76
+ --skipped-credits <card>=<credits>
77
+ For a named card, statement credits you don't use:
78
+ --skipped-credits amex-gold=dunkin. Repeat per card.
76
79
  --opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
77
80
  a first-year rate. Repeat per card.
78
81
  --member <name> A membership you have, in place of the saved ones.
@@ -114,6 +117,7 @@ export async function cardsToUse(catalog, file, io, named) {
114
117
  cards: named.cards.map((id) => ({
115
118
  id,
116
119
  ...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
120
+ ...(named.skipped?.[id] ? { skippedCredits: named.skipped[id] } : {}),
117
121
  ...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
118
122
  })),
119
123
  memberships: named.members.length > 0 ? named.members : saved.memberships,
@@ -20,11 +20,17 @@ const CARD_OPTIONS = `Options:
20
20
  --activated <rules> Rules you've activated with the issuer, comma
21
21
  separated. Replaces the current list.
22
22
  --enrolled <credits> Statement credits you've enrolled in with the
23
- issuer, comma separated, e.g. --enrolled lululemon.
24
- Replaces the current list.
23
+ issuer, or use if they need no enrolling, comma
24
+ separated, e.g. --enrolled lululemon. They count
25
+ against the annual fee. Replaces the current list.
25
26
  --credit-used <credit>=<dollars>
26
27
  How much of a credit you've received this period,
27
28
  e.g. --credit-used lululemon=40. Repeat for each.
29
+ --skipped-credits <credits>
30
+ Statement credits you don't use, comma separated,
31
+ e.g. --skipped-credits dunkin. They're left out of
32
+ picks, the to-do list and annual-fee math.
33
+ Replaces the current list.
28
34
  --cap-spent <cap>=<dollars>
29
35
  Spending toward a cap so far this period, e.g.
30
36
  --cap-spent us-supermarkets=1200. Repeat for each.
@@ -150,6 +156,7 @@ export async function runWalletCommand(command, argv, io) {
150
156
  opened: { type: 'string' },
151
157
  activated: { type: 'string' },
152
158
  enrolled: { type: 'string' },
159
+ 'skipped-credits': { type: 'string' },
153
160
  'credit-used': { type: 'string', multiple: true },
154
161
  'cap-spent': { type: 'string', multiple: true },
155
162
  'milestone-spent': { type: 'string', multiple: true },
@@ -183,6 +190,7 @@ export async function runWalletCommand(command, argv, io) {
183
190
  values.opened !== undefined ||
184
191
  values.activated !== undefined ||
185
192
  values.enrolled !== undefined ||
193
+ values['skipped-credits'] !== undefined ||
186
194
  values['credit-used'] !== undefined ||
187
195
  values['cap-spent'] !== undefined ||
188
196
  values['milestone-spent'] !== undefined ||
@@ -288,12 +296,14 @@ export async function runWalletCommand(command, argv, io) {
288
296
  const n = Number(text.replace(/[$,]/g, ''));
289
297
  return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
290
298
  };
291
- if (values.enrolled !== undefined) {
292
- input.enrolled = values.enrolled
293
- .split(',')
294
- .map((r) => r.trim())
295
- .filter((r) => r !== '');
296
- }
299
+ const idList = (text) => text
300
+ .split(',')
301
+ .map((r) => r.trim())
302
+ .filter((r) => r !== '');
303
+ if (values.enrolled !== undefined)
304
+ input.enrolled = idList(values.enrolled);
305
+ if (values['skipped-credits'] !== undefined)
306
+ input.skippedCredits = idList(values['skipped-credits']);
297
307
  // --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
298
308
  const dollarPairs = (flag, example) => {
299
309
  const pairs = values[flag];
@@ -160,6 +160,19 @@ function cardProblems(card, catalog, merchantIds) {
160
160
  ...card.intro_rates.map((i) => i.id),
161
161
  ...card.credits.map((c) => c.id),
162
162
  ]);
163
+ // An end or start date has to come from the issuer, so a quote names its
164
+ // year ("through 12/31/2027", "Starting in 2027").
165
+ for (const [id, date] of [
166
+ ...card.credits.map((c) => [c.id, c.valid_to]),
167
+ ...card.milestones.map((m) => [m.id, m.valid_from]),
168
+ ]) {
169
+ if (date === undefined)
170
+ continue;
171
+ const year = date.slice(0, 4);
172
+ const named = new RegExp(`(^|\\D)(${year}|\\d{1,2}/\\d{1,2}/${year.slice(2)})(\\D|$)`);
173
+ if (!card.evidence.some((e) => e.rule === id && named.test(e.quote)))
174
+ problems.push(`${where} ${id}: no evidence quote names its date's year`);
175
+ }
163
176
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
164
177
  const backed = new Set(card.evidence.map((e) => e.rule));
165
178
  for (const e of card.evidence) {
@@ -44,6 +44,10 @@ function fieldLines(field, f, indent) {
44
44
  return f.enrolled === undefined
45
45
  ? ''
46
46
  : `${indent}enrolled: ${flowList(f.enrolled)}\n`;
47
+ case 'skipped_credits':
48
+ return f.skipped_credits === undefined
49
+ ? ''
50
+ : `${indent}skipped_credits: ${flowList(f.skipped_credits)}\n`;
47
51
  case 'credit_usage': {
48
52
  if (f.credit_usage === undefined)
49
53
  return '';
@@ -106,6 +110,7 @@ const FIELDS = [
106
110
  'milestone_usage',
107
111
  'enrolled',
108
112
  'credit_usage',
113
+ 'skipped_credits',
109
114
  'bonus',
110
115
  ];
111
116
  function cardsSeq(text) {
@@ -1,8 +1,16 @@
1
1
  import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
2
- import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
2
+ import { CAP_PERIODS, choiceName, askCreditText, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
3
3
  import { milestonePays } from "./milestones.js";
4
- import { optionName } from "./rule-text.js";
4
+ import { creditLabel, optionName } from "./rule-text.js";
5
5
  import { activationsDue, walletSummary, } from "./wallet-summary.js";
6
+ // "5% and 2% categories", "top category": a choice already named for its
7
+ // category isn't followed by "categories" again.
8
+ function pickWhat(names) {
9
+ const [only] = names;
10
+ if (names.length === 1 && only !== undefined && / category$/.test(only))
11
+ return only;
12
+ return `${listed(names)} categories`;
13
+ }
6
14
  // What the wallet's cards need this period, soonest first: bonuses to
7
15
  // activate, categories to pick, caps running low, welcome bonuses to finish,
8
16
  // merchant credits to enroll in or use, and rotating categories the issuer
@@ -41,7 +49,7 @@ shortName) {
41
49
  items.push({
42
50
  kind: 'pick',
43
51
  cardId: card.id,
44
- text: `Pick ${possessive(name)} ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
52
+ text: `Pick ${possessive(name)} ${pickWhat(unpicked.map((c) => choiceName(c.id)))} for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
45
53
  due,
46
54
  });
47
55
  }
@@ -94,7 +102,19 @@ shortName) {
94
102
  due: m.until,
95
103
  });
96
104
  }
97
- const toEnroll = s.credits.filter((c) => !c.enrolled);
105
+ const toAsk = s.credits.filter((c) => !c.enroll && !c.confirmed && !c.skipped);
106
+ if (toAsk.length > 0)
107
+ items.push({
108
+ kind: 'enroll',
109
+ cardId: card.id,
110
+ ref: toAsk.map((c) => c.id).join(','),
111
+ text: askCreditText(name, toAsk.map((c) => {
112
+ const credit = card.credits.find((x) => x.id === c.id);
113
+ return credit ? creditLabel(catalog, credit) : c.name;
114
+ })),
115
+ due: null,
116
+ });
117
+ const toEnroll = s.credits.filter((c) => !c.enrolled && !c.skipped);
98
118
  if (toEnroll.length > 0)
99
119
  items.push({
100
120
  kind: 'enroll',
@@ -104,7 +124,7 @@ shortName) {
104
124
  due: null,
105
125
  });
106
126
  for (const c of s.credits) {
107
- if (!c.enrolled)
127
+ if (!c.enrolled || c.skipped)
108
128
  continue;
109
129
  // One that needs no enrolling shows once the cardholder uses it, so a
110
130
  // card's every credit isn't a to-do for someone who never shops there.
@@ -1,7 +1,7 @@
1
1
  import { merchantFamily } from "../merchants/resolve.js";
2
2
  import { categoryMatches } from "./evaluate.js";
3
- import { periodEnd, periodKey } from "./periods.js";
4
- import { creditName } from "./rule-text.js";
3
+ import { inEffect, periodEnd, periodKey } from "./periods.js";
4
+ import { creditLabel, creditName } from "./rule-text.js";
5
5
  import { roundCents } from "./text.js";
6
6
  // Only a store itself has a credit: a search for a kind of purchase
7
7
  // ("clothing") never does.
@@ -16,6 +16,15 @@ export function creditCovers(catalog, credit, merchant) {
16
16
  }
17
17
  // A credit that needs enrolling counts only once the wallet says it's done.
18
18
  const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
+ // The cardholder said they don't use it: it counts nowhere, enrolled or not.
20
+ export const isSkipped = (credit, held) => (held.skipped_credits ?? []).includes(credit.id);
21
+ // The cardholder said they use it: it's in `enrolled` (for one that needs
22
+ // no enrolling, that means "using it"), or one that needs no enrolling has
23
+ // already paid out. Until then a credit doesn't offset the annual fee.
24
+ export const creditConfirmed = (credit, held) => (held.enrolled ?? []).includes(credit.id) ||
25
+ (credit.enroll !== true && held.credit_usage?.[credit.id] !== undefined);
26
+ // A credit that counts against the fee: confirmed and not skipped.
27
+ export const creditInUse = (credit, held) => creditConfirmed(credit, held) && !isSkipped(credit, held);
19
28
  function creditState(catalog, credit, held, date) {
20
29
  const anniversary = held.anniversary ?? null;
21
30
  const period = periodKey(credit.period, date, anniversary);
@@ -30,18 +39,23 @@ function creditState(catalog, credit, held, date) {
30
39
  amount: credit.amount,
31
40
  period: credit.period,
32
41
  enrolled: isEnrolled(credit, held),
42
+ skipped: isSkipped(credit, held),
43
+ confirmed: creditConfirmed(credit, held),
33
44
  used,
45
+ minAmount: credit.min_amount ?? null,
34
46
  left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
35
47
  until: period === null ? null : periodEnd(credit.period, period, anniversary),
36
48
  };
37
49
  }
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;
50
+ // The card's credits the issuer still offers on `date`.
51
+ export const liveCredits = (card, date) => card.credits.filter((c) => inEffect(c, date));
52
+ export const creditStates = (catalog, card, held, date) => liveCredits(card, date).map((c) => creditState(catalog, c, held, date));
53
+ export const counts = (s) => s.enrolled && !s.skipped && s.left !== null && s.left > 0;
40
54
  // The card's credit at this merchant, if any: one that counts first, then
41
- // the one with the most left.
55
+ // the one with the most left. A skipped credit isn't mentioned at all.
42
56
  export function creditAt(catalog, card, held, merchant, date) {
43
- const here = card.credits
44
- .filter((c) => creditCovers(catalog, c, merchant))
57
+ const here = liveCredits(card, date)
58
+ .filter((c) => !isSkipped(c, held) && creditCovers(catalog, c, merchant))
45
59
  .map((c) => creditState(catalog, c, held, date))
46
60
  .sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
47
61
  return here[0] ?? null;
@@ -53,6 +67,8 @@ export function creditDollars(s, amount) {
53
67
  return null;
54
68
  if (!counts(s) || amount <= 0)
55
69
  return 0;
70
+ if (s.credit.min_amount !== undefined && amount < s.credit.min_amount)
71
+ return 0;
56
72
  return roundCents(Math.min(amount, s.left ?? 0));
57
73
  }
58
74
  const PER_YEAR = {
@@ -62,4 +78,37 @@ const PER_YEAR = {
62
78
  calendar_year: 1,
63
79
  cardmember_year: 1,
64
80
  };
81
+ const monthIndex = (date) => Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7)) - 1;
82
+ // How many times the credit pays in the 12 months from `date`, counting the
83
+ // period `date` is in: every period's, or for a credit that ends, those that
84
+ // start by its last day.
85
+ function payoutsAYear(credit, date) {
86
+ const all = PER_YEAR[credit.period];
87
+ if (credit.valid_to === undefined)
88
+ return all;
89
+ if (!inEffect(credit, date))
90
+ return 0;
91
+ if (all === 1)
92
+ return 1;
93
+ const step = 12 / all;
94
+ const first = monthIndex(date) - (monthIndex(date) % step);
95
+ const starts = Math.floor((monthIndex(credit.valid_to) - first) / step) + 1;
96
+ return Math.min(all, starts);
97
+ }
98
+ // Dollars a year at face value, the most first: for ordering cards by credit.
65
99
  export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
100
+ function creditsYear(catalog, card, credits, date) {
101
+ const live = credits.filter((c) => inEffect(c, date));
102
+ return {
103
+ cardId: card.id,
104
+ yearly: roundCents(live.reduce((sum, c) => sum + c.amount * payoutsAYear(c, date), 0)),
105
+ creditIds: live.map((c) => c.id),
106
+ names: live.map((c) => creditLabel(catalog, c)),
107
+ };
108
+ }
109
+ // Every credit on the card, as if each were used in full: for a card the
110
+ // person doesn't hold.
111
+ export const allCreditsYear = (catalog, card, date) => creditsYear(catalog, card, card.credits, date);
112
+ // The credits a cardholder uses, in full each period: what counts against
113
+ // the fee of a card they hold.
114
+ export const usedCreditsYear = (catalog, card, held, date) => creditsYear(catalog, card, card.credits.filter((c) => creditInUse(c, held)), date);
@@ -1,6 +1,7 @@
1
1
  import { merchantFamily } from "../merchants/resolve.js";
2
2
  import { EPSILON } from "./compare.js";
3
3
  import { introCovers, introWindows, withIntro, } from "./intro.js";
4
+ import { effectiveMemberships } from "./memberships.js";
4
5
  import { inEffect, periodKey } from "./periods.js";
5
6
  // PayPal checkout happens online, so paying through it says the channel.
6
7
  const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
@@ -16,7 +17,7 @@ export function purchaseContext(catalog, wallet, merchant, category, purchase) {
16
17
  country: purchase.country,
17
18
  date: purchase.date,
18
19
  amount: purchase.amount,
19
- memberships: new Set(wallet.memberships),
20
+ memberships: new Set(effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id))),
20
21
  payment,
21
22
  };
22
23
  }
@@ -120,6 +121,9 @@ function applicability(card, rule, ctx, held, picked) {
120
121
  return 'no';
121
122
  if (!inEffect(rule, ctx.date))
122
123
  return 'no';
124
+ if (rule.min_amount !== undefined &&
125
+ (ctx.amount === null || ctx.amount < rule.min_amount))
126
+ return 'no';
123
127
  const membership = rule.requires?.membership;
124
128
  if (membership !== undefined && !ctx.memberships.has(membership))
125
129
  return 'no';
@@ -249,3 +253,19 @@ export function paymentUpgrade(card, held, ctx, rate) {
249
253
  }
250
254
  return best;
251
255
  }
256
+ // The smallest purchase amount (one a rule on the card asks for) that would
257
+ // earn more than `rate`, and what it would earn. Only while the purchase has
258
+ // no amount.
259
+ export function amountUpgrade(card, held, ctx, rate) {
260
+ if (ctx.amount !== null)
261
+ return null;
262
+ const amounts = [
263
+ ...new Set(card.rules.flatMap((r) => r.min_amount ?? [])),
264
+ ].sort((a, b) => a - b);
265
+ for (const amount of amounts) {
266
+ const larger = evaluateCard(card, held, { ...ctx, amount }).best;
267
+ if (larger.rate > rate + EPSILON)
268
+ return { amount, rate: larger.rate, nominalRate: larger.nominalRate };
269
+ }
270
+ return null;
271
+ }
@@ -1,6 +1,6 @@
1
1
  import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
2
- import { creditCovers, creditPerYear } from "./credits.js";
3
- import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
2
+ import { creditCovers, creditPerYear, liveCredits } from "./credits.js";
3
+ import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, } from "./evaluate.js";
4
4
  import { creditName } from "./rule-text.js";
5
5
  import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
6
6
  import { choicePeriod, periodKey } from "./periods.js";
@@ -8,7 +8,7 @@ import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./reco
8
8
  import { rotationGap } from "./rotation.js";
9
9
  import { membershipsUsed } from "./setup.js";
10
10
  import { optionName } from "./rule-text.js";
11
- import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, capSentence, } from "./text.js";
11
+ import { amountUnknownText, capSentence, categoryPhrase, choiceName, codesAs, creditBack, creditThen, creditTerms, earnText, listed, paidText, PAYMENT_PHRASES, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
12
12
  import { cardValuation } from "./valuation.js";
13
13
  import { EPSILON } from "./compare.js";
14
14
  export const ALL_PERSONAL_CARDS = {
@@ -169,6 +169,7 @@ export function exploreCards(catalog, query, options, resolved) {
169
169
  };
170
170
  const ranked = [];
171
171
  const ifPaid = new Map();
172
+ const ifLarger = new Map();
172
173
  const excluded = [];
173
174
  // A Costco purchase means a Costco member: no membership to point out.
174
175
  const members = paidMemberships(catalog, {
@@ -192,6 +193,14 @@ export function exploreCards(catalog, query, options, resolved) {
192
193
  const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
193
194
  const gap = rotationGap(card, query.date);
194
195
  const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
196
+ const larger = amountUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
197
+ if (larger)
198
+ ifLarger.set(card.id, {
199
+ amount: larger.amount,
200
+ earn: earnText(catalog, larger.nominalRate, top.currency),
201
+ standard: earnText(catalog, top.rate, top.currency),
202
+ returnPct: larger.rate * top.centsPerUnit - (top.foreignFee ?? 0),
203
+ });
195
204
  if (paid)
196
205
  ifPaid.set(card.id, {
197
206
  payment: paid.payment,
@@ -233,17 +242,17 @@ export function exploreCards(catalog, query, options, resolved) {
233
242
  ranked.sort(compareExplored);
234
243
  return {
235
244
  merchant,
236
- credits: creditPicks(catalog, merchant, ranked),
245
+ credits: creditPicks(catalog, merchant, ranked, query.date),
237
246
  ranked,
238
247
  excluded,
239
- assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
248
+ assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger),
240
249
  warnings: exploreWarnings(catalog, merchant),
241
250
  };
242
251
  }
243
- function creditPicks(catalog, merchant, ranked) {
252
+ function creditPicks(catalog, merchant, ranked, date) {
244
253
  const picks = ranked.flatMap((entry) => {
245
254
  const card = catalog.cards.find((c) => c.id === entry.cardId);
246
- return ((card?.credits ?? [])
255
+ return ((card ? liveCredits(card, date) : [])
247
256
  // A credit for a whole category (every restaurant) isn't a store's.
248
257
  .filter((c) => c.match.merchants !== undefined &&
249
258
  creditCovers(catalog, c, merchant))
@@ -263,7 +272,8 @@ function creditPicks(catalog, merchant, ranked) {
263
272
  period: credit.period,
264
273
  enroll: credit.enroll === true,
265
274
  note: credit.note ?? null,
266
- back: creditBack(credit),
275
+ back: `${creditBack(credit)}${creditTerms(credit)}`,
276
+ terms: creditTerms(credit),
267
277
  earn,
268
278
  text: creditThen(credit, earn),
269
279
  evidence: evidence
@@ -274,7 +284,7 @@ function creditPicks(catalog, merchant, ranked) {
274
284
  });
275
285
  return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
276
286
  }
277
- function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
287
+ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger) {
278
288
  const assumptions = [];
279
289
  if (merchant.source === 'category') {
280
290
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
@@ -302,6 +312,14 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
302
312
  continue;
303
313
  assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
304
314
  }
315
+ for (const entry of ranked) {
316
+ const larger = ifLarger.get(entry.cardId);
317
+ if (!larger || !top)
318
+ continue;
319
+ if (entry !== top && larger.returnPct <= top.effectiveReturnPct + EPSILON)
320
+ continue;
321
+ assumptions.push(amountUnknownText(entry.cardName, larger.amount, larger.earn, larger.standard));
322
+ }
305
323
  return assumptions;
306
324
  }
307
325
  function exploreWarnings(catalog, merchant) {
@@ -1,4 +1,4 @@
1
- import { listed, money, withArticle, words } from "./text.js";
1
+ import { listed, money, shortCardName, withArticle, words } from "./text.js";
2
2
  export function membership(catalog, id) {
3
3
  return catalog.memberships.find((m) => m.id === id);
4
4
  }
@@ -11,16 +11,24 @@ export function sameGroup(catalog, a, b) {
11
11
  const group = membership(catalog, a)?.group;
12
12
  return group !== undefined && group === membership(catalog, b)?.group;
13
13
  }
14
+ // The memberships a person has: the ones they've said, plus the one each of
15
+ // their cards can't be held without (a Costco membership for Costco
16
+ // Anywhere). Gates a rule's `requires.membership` as well as fees. Only the
17
+ // person's own cards (or those named for an answer) belong in `cardIds`.
18
+ export function effectiveMemberships(catalog, memberships, cardIds) {
19
+ const out = new Set(memberships);
20
+ for (const card of catalog.cards) {
21
+ const id = card.requires_membership;
22
+ if (id !== undefined && cardIds.includes(card.id))
23
+ out.add(id);
24
+ }
25
+ return [...out];
26
+ }
14
27
  // Memberships the person already pays for: the ones they've said, the ones a
15
28
  // card they hold needs, and the ones their spending shows (only members shop
16
29
  // at Costco).
17
30
  export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
18
- const paid = new Set(memberships);
19
- for (const card of catalog.cards) {
20
- const id = card.requires_membership;
21
- if (id !== undefined && heldCards.includes(card.id))
22
- paid.add(id);
23
- }
31
+ const paid = new Set(effectiveMemberships(catalog, memberships, heldCards));
24
32
  for (const m of catalog.memberships)
25
33
  if (m.members_shop_at.some((id) => merchantIds.includes(id)))
26
34
  paid.add(m.id);
@@ -62,6 +70,10 @@ export function membershipNeeded(catalog, card, paid) {
62
70
  : [];
63
71
  }
64
72
  export function membershipNote(catalog, m) {
65
- const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
66
- return `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
73
+ const names = m.cardIds.map((id) => shortCardName(catalog.cards.find((c) => c.id === id)?.name ?? id));
74
+ const needs = `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}`;
75
+ const cost = m.annualFee === 0
76
+ ? ''
77
+ : `, ${money(m.annualFee)} a year, counted here like an annual fee`;
78
+ return `${needs}${cost}. If you have one, add it to your memberships.`;
67
79
  }
@@ -1,7 +1,11 @@
1
- import { periodEnd, periodKey } from "./periods.js";
2
- import { listed, MILESTONE_PERIODS, money, roundCents, wholeDollars, } from "./text.js";
1
+ import { inEffect, periodEnd, periodKey } from "./periods.js";
2
+ import { listed, MILESTONE_PERIODS, shortDate, money, roundCents, wholeDollars, } from "./text.js";
3
+ // Spend milestones: a bonus paid once a period after a spending total. Only
4
+ // spending the wallet tracks counts; earnrate never assumes the rest.
5
+ // The card's milestones the issuer runs on `date`.
6
+ export const liveMilestones = (card, date) => card.milestones.filter((m) => inEffect(m, date));
3
7
  export function milestoneProgress(card, held, date) {
4
- return card.milestones.map((milestone) => {
8
+ return liveMilestones(card, date).map((milestone) => {
5
9
  const period = periodKey(milestone.period, date, held.anniversary ?? null);
6
10
  const usage = held.milestone_usage?.[milestone.id];
7
11
  return {
@@ -45,6 +49,10 @@ export function milestonePays(m) {
45
49
  }
46
50
  // "the $15,000 calendar-year spend for 10,000 bonus points"
47
51
  export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
52
+ // " starting Jan 1, 2027" for a milestone that hasn't started by `today`.
53
+ export const milestoneStarts = (m, today) => m.valid_from === undefined || m.valid_from <= today
54
+ ? ''
55
+ : ` starting ${shortDate(m.valid_from)}`;
48
56
  export function reachedText(card, hit, centsPerPoint) {
49
57
  const m = card.milestones.find((x) => x.id === hit.id);
50
58
  if (!m)
@@ -1,8 +1,9 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
+ import { allCreditsYear } from "./credits.js";
2
3
  import { matchesFilters } from "./explore.js";
3
4
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
5
  import { inEffect } from "./periods.js";
5
- import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
6
+ import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
6
7
  import { cardValuation } from "./valuation.js";
7
8
  function ineligibleText(catalog, wallet, notWith) {
8
9
  const held = wallet.cards
@@ -49,6 +50,8 @@ export function compareOffers(catalog, wallet, filters, today) {
49
50
  0;
50
51
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
51
52
  const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
53
+ const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
54
+ const credits = allCreditsYear(catalog, card, today).yearly;
52
55
  offers.push({
53
56
  cardId: card.id,
54
57
  cardName: card.name,
@@ -68,7 +71,9 @@ export function compareOffers(catalog, wallet, filters, today) {
68
71
  bonusValue: roundCents(value),
69
72
  membershipFee,
70
73
  ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
71
- firstYearValue: roundCents(value - (firstYearFee ?? 0) - membershipFee),
74
+ firstYearValue,
75
+ credits,
76
+ firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
72
77
  held: held.has(card.id),
73
78
  lastVerified: card.last_verified,
74
79
  verifiedBy: card.verified_by,
@@ -80,8 +85,8 @@ export function compareOffers(catalog, wallet, filters, today) {
80
85
  const notes = [
81
86
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
82
87
  `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.`,
83
- "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.",
84
- CREDITS_LEFT_OUT,
88
+ "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 its perks.",
89
+ CREDITS_APART,
85
90
  ];
86
91
  return { offers, withoutOffer, notes };
87
92
  }
@@ -1,6 +1,6 @@
1
1
  import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
- import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
3
+ import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { introWindows, openedOn, withIntro } from "./intro.js";
5
5
  import { membershipName } from "./memberships.js";
6
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
@@ -9,7 +9,7 @@ import { creditAt, creditDollars } from "./credits.js";
9
9
  import { ruleWhat } from "./rule-text.js";
10
10
  import { rotationGap } from "./rotation.js";
11
11
  import { EPSILON, isClose } from "./compare.js";
12
- import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
12
+ import { amountUnknownText, CAP_ADJECTIVES, categoryPhrase, codesAs, creditAnswerText, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, possessive, roundCents, shortDate, wholeDollars, } from "./text.js";
13
13
  import { cardValuation } from "./valuation.js";
14
14
  export const STALE_AFTER_DAYS = 180;
15
15
  // The engine only tells the U.S. from everywhere else.
@@ -99,6 +99,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
99
99
  }
100
100
  : null;
101
101
  const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
102
+ const larger = amountUpgrade(card, held, ctx, evaluation.best.rate);
102
103
  const credit = options.credits === false
103
104
  ? null
104
105
  : creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
@@ -133,6 +134,15 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
133
134
  creditPct,
134
135
  }
135
136
  : null,
137
+ ifLarger: larger
138
+ ? {
139
+ amount: larger.amount,
140
+ rate: larger.nominalRate,
141
+ returnPct: (perDollar - evaluation.best.rate + larger.rate) *
142
+ valuation.centsPerUnit -
143
+ fee,
144
+ }
145
+ : null,
136
146
  });
137
147
  }
138
148
  entries.sort(compareEntries);
@@ -375,6 +385,15 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
375
385
  const currency = e.valuation.currency;
376
386
  assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
377
387
  }
388
+ for (const e of entries) {
389
+ if (!e.ifLarger || !winner)
390
+ continue;
391
+ if (e !== winner &&
392
+ e.ifLarger.returnPct <= winner.effectiveReturnPct + EPSILON)
393
+ continue;
394
+ const currency = e.valuation.currency;
395
+ assumptions.push(amountUnknownText(displayName(e), e.ifLarger.amount, earnText(catalog, e.ifLarger.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
396
+ }
378
397
  for (const e of entries) {
379
398
  if (!e.ifNew || !winner)
380
399
  continue;