earnrate 0.2.10 → 0.2.12

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 (130) hide show
  1. package/README.md +31 -11
  2. package/data/CHANGELOG.md +154 -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 +1 -0
  6. package/data/cards/amex-blue-cash-preferred.yaml +7 -0
  7. package/data/cards/amex-business-gold.yaml +134 -0
  8. package/data/cards/amex-business-platinum.yaml +115 -0
  9. package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
  10. package/data/cards/amex-delta-skymiles-gold.yaml +122 -0
  11. package/data/cards/amex-delta-skymiles-platinum.yaml +120 -0
  12. package/data/cards/amex-delta-skymiles-reserve.yaml +82 -0
  13. package/data/cards/amex-gold.yaml +1 -0
  14. package/data/cards/amex-hilton-honors-aspire.yaml +142 -0
  15. package/data/cards/amex-hilton-honors-business.yaml +93 -0
  16. package/data/cards/amex-hilton-honors-surpass.yaml +153 -0
  17. package/data/cards/amex-hilton-honors.yaml +119 -0
  18. package/data/cards/amex-marriott-bonvoy-bevy.yaml +118 -0
  19. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +93 -0
  20. package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
  21. package/data/cards/amex-platinum.yaml +15 -0
  22. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +14 -0
  23. package/data/cards/bank-of-america-atmos-rewards-business.yaml +15 -0
  24. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +17 -0
  25. package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
  26. package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
  27. package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
  28. package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
  29. package/data/cards/barclays-jetblue-plus.yaml +86 -0
  30. package/data/cards/bilt-blue.yaml +60 -0
  31. package/data/cards/bilt-obsidian.yaml +1 -0
  32. package/data/cards/bilt-palladium.yaml +76 -0
  33. package/data/cards/capital-one-kohls-card.yaml +1 -0
  34. package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
  35. package/data/cards/capital-one-quicksilver.yaml +82 -0
  36. package/data/cards/capital-one-savor.yaml +2 -0
  37. package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
  38. package/data/cards/capital-one-venture-x-business.yaml +97 -0
  39. package/data/cards/capital-one-venture-x.yaml +2 -0
  40. package/data/cards/capital-one-venture.yaml +2 -0
  41. package/data/cards/chase-aeroplan.yaml +30 -0
  42. package/data/cards/chase-freedom-flex.yaml +1 -0
  43. package/data/cards/chase-freedom-rise.yaml +29 -0
  44. package/data/cards/chase-freedom-unlimited.yaml +1 -0
  45. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -0
  46. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +2 -0
  47. package/data/cards/chase-ihg-one-rewards-premier.yaml +1 -0
  48. package/data/cards/chase-ihg-one-rewards.yaml +1 -0
  49. package/data/cards/chase-ink-business-cash.yaml +1 -0
  50. package/data/cards/chase-ink-business-preferred.yaml +1 -0
  51. package/data/cards/chase-ink-business-unlimited.yaml +69 -0
  52. package/data/cards/chase-marriott-bonvoy-bold.yaml +124 -0
  53. package/data/cards/chase-marriott-bonvoy-boundless.yaml +78 -0
  54. package/data/cards/chase-prime-visa.yaml +1 -0
  55. package/data/cards/chase-sapphire-preferred.yaml +1 -0
  56. package/data/cards/chase-sapphire-reserve.yaml +30 -0
  57. package/data/cards/chase-southwest-performance-business.yaml +2 -0
  58. package/data/cards/chase-southwest-plus.yaml +2 -0
  59. package/data/cards/chase-southwest-premier-business.yaml +2 -0
  60. package/data/cards/chase-southwest-premier.yaml +2 -0
  61. package/data/cards/chase-southwest-priority.yaml +2 -0
  62. package/data/cards/chase-united-business.yaml +11 -0
  63. package/data/cards/chase-united-club-business.yaml +20 -0
  64. package/data/cards/chase-united-club.yaml +32 -0
  65. package/data/cards/chase-united-explorer.yaml +20 -0
  66. package/data/cards/chase-united-gateway.yaml +19 -0
  67. package/data/cards/chase-united-quest.yaml +20 -0
  68. package/data/cards/citi-aadvantage-business.yaml +14 -1
  69. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  70. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  71. package/data/cards/citi-aadvantage-mileup.yaml +2 -0
  72. package/data/cards/citi-aadvantage-platinum-select.yaml +14 -1
  73. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  74. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  75. package/data/cards/citi-custom-cash.yaml +1 -0
  76. package/data/cards/citi-double-cash.yaml +1 -0
  77. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  78. package/data/cards/citi-strata-elite.yaml +1 -0
  79. package/data/cards/citi-strata-premier.yaml +1 -0
  80. package/data/cards/citi-strata.yaml +164 -0
  81. package/data/cards/discover-it-cash-back.yaml +188 -0
  82. package/data/cards/paypal-cashback-mastercard.yaml +41 -0
  83. package/data/cards/sams-club-mastercard.yaml +68 -0
  84. package/data/cards/sofi-unlimited-2.yaml +1 -0
  85. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  86. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  87. package/data/cards/td-bank-target-circle-card.yaml +1 -0
  88. package/data/cards/usbank-altitude-connect.yaml +150 -0
  89. package/data/cards/usbank-altitude-go.yaml +118 -0
  90. package/data/cards/usbank-cash-plus.yaml +1 -0
  91. package/data/cards/wells-fargo-active-cash.yaml +1 -0
  92. package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
  93. package/data/cards/wells-fargo-autograph.yaml +1 -0
  94. package/data/categories.yaml +3 -1
  95. package/data/currencies.yaml +60 -0
  96. package/data/issuers.yaml +25 -1
  97. package/data/memberships.yaml +10 -0
  98. package/data/merchants/overrides.yaml +515 -1
  99. package/data/merchants/seed.yaml +883 -6
  100. package/dist/cli/import.js +2 -2
  101. package/dist/cli/wallet.js +8 -1
  102. package/dist/data/integrity.js +37 -0
  103. package/dist/data/wallet-edit.js +3 -1
  104. package/dist/engine/cheat-sheet.js +3 -0
  105. package/dist/engine/checklist.js +14 -3
  106. package/dist/engine/evaluate.js +19 -7
  107. package/dist/engine/intro.js +36 -0
  108. package/dist/engine/recommend.js +41 -10
  109. package/dist/engine/record.js +2 -0
  110. package/dist/engine/rule-text.js +15 -1
  111. package/dist/engine/setup.js +6 -0
  112. package/dist/engine/text.js +23 -0
  113. package/dist/engine/wallet-change.js +20 -3
  114. package/dist/engine/wallet-summary.js +24 -4
  115. package/dist/format/card-art.js +21 -0
  116. package/dist/format/cards.js +1 -0
  117. package/dist/format/wallet-yaml.js +1 -0
  118. package/dist/format/wallet.js +10 -4
  119. package/dist/import/analyze.js +1 -0
  120. package/dist/mcp/inputs.js +3 -0
  121. package/dist/mcp/prompts.js +90 -0
  122. package/dist/mcp/schemas.js +17 -0
  123. package/dist/mcp/server.js +2 -0
  124. package/dist/mcp/tools/account.js +8 -5
  125. package/dist/mcp/tools/answers.js +7 -0
  126. package/dist/mcp/tools/wallet.js +7 -2
  127. package/dist/schema/card.js +23 -0
  128. package/dist/schema/issuer.js +4 -0
  129. package/dist/schema/wallet.js +3 -0
  130. package/package.json +12 -3
@@ -13,7 +13,7 @@ import { cardValuation } from "../engine/valuation.js";
13
13
  import { analyzeStatement, } from "../import/analyze.js";
14
14
  import { parseStatement } from "../import/statement.js";
15
15
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
16
- import { currencyName, dollarsAndCents } from "../engine/text.js";
16
+ import { currencyName, dollarsAndCents, periodPhrase } from "../engine/text.js";
17
17
  const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
18
18
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
19
19
 
@@ -133,7 +133,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
133
133
  out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
134
134
  }
135
135
  for (const m of a.milestones) {
136
- out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} for ${m.period}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
136
+ out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} ${periodPhrase(m.periodKind, m.period)}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
137
137
  }
138
138
  return `${out.join('\n')}\n`;
139
139
  }
@@ -14,6 +14,9 @@ const CARD_OPTIONS = `Options:
14
14
  --pick five-percent=fast-food,movie-theaters.
15
15
  Repeat for each choice.
16
16
  --anniversary <MM-DD> Account anniversary, for caps that reset on it.
17
+ --opened <date> Optional: the day the account opened (YYYY-MM-DD),
18
+ for cards with a first-year rate. Without it the
19
+ card is ranked at its standard rates.
17
20
  --activated <rules> Rules you've activated with the issuer, comma
18
21
  separated. Replaces the current list.
19
22
  --bonus-opened <date> Track a welcome bonus: the day the account opened
@@ -44,7 +47,7 @@ ${CARD_OPTIONS}
44
47
  update: `Usage: ${CLI_NAME} update <card-id> [options]
45
48
 
46
49
  Changes a card in your wallet: picks for this period, label, anniversary,
47
- activations or welcome bonus. Picks for the current period are merged with
50
+ opening date, activations or welcome bonus. Picks for the current period are merged with
48
51
  ones already set.
49
52
 
50
53
  ${CARD_OPTIONS}
@@ -130,6 +133,7 @@ export async function runWalletCommand(command, argv, io) {
130
133
  label: { type: 'string' },
131
134
  pick: { type: 'string', multiple: true },
132
135
  anniversary: { type: 'string' },
136
+ opened: { type: 'string' },
133
137
  activated: { type: 'string' },
134
138
  'bonus-opened': { type: 'string' },
135
139
  'bonus-spent': { type: 'string' },
@@ -158,6 +162,7 @@ export async function runWalletCommand(command, argv, io) {
158
162
  const cardOptionsGiven = values.label !== undefined ||
159
163
  values.pick !== undefined ||
160
164
  values.anniversary !== undefined ||
165
+ values.opened !== undefined ||
161
166
  values.activated !== undefined;
162
167
  if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
163
168
  return usage(`${CLI_NAME} ${command} doesn't take card options.`);
@@ -236,6 +241,8 @@ export async function runWalletCommand(command, argv, io) {
236
241
  input.checkoutLabel = values.label;
237
242
  if (values.anniversary !== undefined)
238
243
  input.anniversary = values.anniversary;
244
+ if (values.opened !== undefined)
245
+ input.opened = values.opened;
239
246
  if (values.activated !== undefined) {
240
247
  input.activated = values.activated
241
248
  .split(',')
@@ -1,3 +1,4 @@
1
+ import { resolveMerchant } from "../merchants/resolve.js";
1
2
  import { WALLET_VERSION } from "../schema/wallet.js";
2
3
  function duplicates(ids) {
3
4
  const seen = new Set();
@@ -38,6 +39,31 @@ function merchantProblems(file, merchants, categories) {
38
39
  }
39
40
  return problems;
40
41
  }
42
+ // Overrides are searched before seed, and a known name followed by more words
43
+ // still matches it, so a name can be taken by another merchant without ever
44
+ // being listed twice.
45
+ function lookupProblems(catalog) {
46
+ const problems = [];
47
+ const { seed, overrides } = catalog.merchants;
48
+ for (const m of [...overrides, ...seed]) {
49
+ for (const key of [m.name, ...m.aliases, ...m.domains]) {
50
+ const found = resolveMerchant(catalog, key, null).merchantId;
51
+ if (found !== m.id)
52
+ problems.push(`merchant ${m.id}: '${key}' resolves to ${found ?? 'no merchant'}`);
53
+ }
54
+ }
55
+ for (const c of catalog.categories)
56
+ for (const word of new Set([
57
+ c.name,
58
+ ...c.aliases,
59
+ c.id.replace(/[._]/g, ' '),
60
+ ])) {
61
+ const found = resolveMerchant(catalog, word, null).merchantId;
62
+ if (found !== null)
63
+ problems.push(`category ${c.id}: '${word}' resolves to ${found}`);
64
+ }
65
+ return problems;
66
+ }
41
67
  function cardProblems(card, catalog, merchantIds) {
42
68
  const where = `card ${card.id}`;
43
69
  const problems = [];
@@ -87,6 +113,7 @@ function cardProblems(card, catalog, merchantIds) {
87
113
  ['cap', card.caps.map((c) => c.id)],
88
114
  ['choice', card.choices.map((c) => c.id)],
89
115
  ['milestone', card.milestones.map((m) => m.id)],
116
+ ['intro rate', card.intro_rates.map((i) => i.id)],
90
117
  ]) {
91
118
  for (const id of duplicates(ids))
92
119
  problems.push(`${where}: duplicate ${kind} id '${id}'`);
@@ -95,6 +122,14 @@ function cardProblems(card, catalog, merchantIds) {
95
122
  for (const m of card.milestones)
96
123
  if (card.rules.some((r) => r.id === m.id))
97
124
  problems.push(`${where}: milestone '${m.id}' has a rule's id`);
125
+ for (const intro of card.intro_rates) {
126
+ if (card.rules.some((r) => r.id === intro.id) ||
127
+ card.milestones.some((m) => m.id === intro.id))
128
+ problems.push(`${where}: intro rate '${intro.id}' has a rule's id`);
129
+ for (const id of intro.rules ?? [])
130
+ if (!card.rules.some((r) => r.id === id))
131
+ problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
132
+ }
98
133
  if (card.foreign_transaction_fee === null &&
99
134
  !card.todo.some((t) => /foreign transaction fee/i.test(t)))
100
135
  problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
@@ -111,6 +146,7 @@ function cardProblems(card, catalog, merchantIds) {
111
146
  ...(card.requires_membership ? ['requires_membership'] : []),
112
147
  ...card.rules.map((r) => r.id),
113
148
  ...card.milestones.map((m) => m.id),
149
+ ...card.intro_rates.map((i) => i.id),
114
150
  ]);
115
151
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
116
152
  const backed = new Set(card.evidence.map((e) => e.rule));
@@ -205,6 +241,7 @@ export function catalogProblems(catalog) {
205
241
  const { seed, overrides } = catalog.merchants;
206
242
  problems.push(...merchantProblems('seed', seed, categories));
207
243
  problems.push(...merchantProblems('overrides', overrides, categories));
244
+ problems.push(...lookupProblems(catalog));
208
245
  const merchantIds = new Set([...seed, ...overrides].map((m) => m.id));
209
246
  for (const m of [...seed, ...overrides]) {
210
247
  for (const via of m.booked_via) {
@@ -31,7 +31,8 @@ const flowList = (ids) => `[${ids.join(', ')}]`;
31
31
  function fieldLines(field, f, indent) {
32
32
  switch (field) {
33
33
  case 'checkout_label':
34
- case 'anniversary': {
34
+ case 'anniversary':
35
+ case 'opened': {
35
36
  const value = f[field];
36
37
  return value == null ? '' : `${indent}${field}: ${scalar(value)}\n`;
37
38
  }
@@ -86,6 +87,7 @@ function fieldLines(field, f, indent) {
86
87
  const FIELDS = [
87
88
  'checkout_label',
88
89
  'anniversary',
90
+ 'opened',
89
91
  'chosen',
90
92
  'activated',
91
93
  'cap_usage',
@@ -20,6 +20,9 @@ export const EVERYDAY_PURCHASES = [
20
20
  { label: 'Drugstores', category: 'drugstore' },
21
21
  { label: 'Everything else', category: 'other' },
22
22
  ];
23
+ // What a quick pick searches for: "Streaming (Netflix)" searches "Netflix",
24
+ // since streaming bonuses follow each issuer's list of services.
25
+ export const quickPickText = (item) => /\((.+)\)$/.exec(item.label)?.[1] ?? item.label;
23
26
  // The best card in the wallet for each everyday kind of purchase on `date`,
24
27
  // with caps, picks and activations as the wallet has them.
25
28
  export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
@@ -1,5 +1,5 @@
1
1
  import { currentPeriodEnd, daysBetween } from "./periods.js";
2
- import { CAP_PERIODS, choiceName, listed, money, shortDate, wholeDollars, } from "./text.js";
2
+ import { CAP_PERIODS, choiceName, introEndsText, listed, money, shortDate, wholeDollars, } from "./text.js";
3
3
  import { milestonePays } from "./milestones.js";
4
4
  import { optionName } from "./rule-text.js";
5
5
  import { activationsDue, walletSummary, } from "./wallet-summary.js";
@@ -18,12 +18,12 @@ shortName) {
18
18
  const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
19
19
  for (const a of activationsDue(catalog, card, held, today)) {
20
20
  const starts = a.from !== null && a.from > today
21
- ? `, starting ${shortDate(a.from)}`
21
+ ? ` (it starts ${shortDate(a.from)})`
22
22
  : '';
23
23
  items.push({
24
24
  kind: 'activate',
25
25
  cardId: card.id,
26
- text: `Activate ${name}'s bonus on ${a.what}${starts}${a.activateBy === null ? '' : `, by ${shortDate(a.activateBy)}`}.`,
26
+ text: `Activate ${name}'s bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
27
27
  due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
28
28
  });
29
29
  }
@@ -103,6 +103,17 @@ shortName) {
103
103
  due: b.deadline,
104
104
  });
105
105
  }
106
+ const quarterEnd = currentPeriodEnd('quarter', today, null);
107
+ if (s.intro?.state === 'active' &&
108
+ s.intro.until !== null &&
109
+ quarterEnd !== null &&
110
+ s.intro.until <= quarterEnd)
111
+ items.push({
112
+ kind: 'intro',
113
+ cardId: card.id,
114
+ text: introEndsText(name, s.intro.days, s.intro.until),
115
+ due: s.intro.until,
116
+ });
106
117
  if (s.rotationGap !== null) {
107
118
  items.push({
108
119
  kind: 'rotation',
@@ -1,3 +1,4 @@
1
+ import { introCovers, introWindows, withIntro, } from "./intro.js";
1
2
  import { inEffect, periodKey } from "./periods.js";
2
3
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
3
4
  return {
@@ -167,10 +168,16 @@ function beats(a, aRate, b, bRate) {
167
168
  }
168
169
  export function evaluateCard(card, held, ctx) {
169
170
  const { picked, staleChoice } = pickedCategories(card, held, ctx.date);
171
+ const windows = introWindows(card, held, ctx.date);
172
+ const introFor = (ruleId) => windows.filter((w) => introCovers(w.intro, ruleId));
173
+ const rateOf = (rule) => withIntro(rule.rate, rule.id, windows);
174
+ const baseRate = withIntro(card.base_rate, null, windows);
170
175
  const base = {
171
176
  rule: null,
172
- nominalRate: card.base_rate,
173
- rate: card.base_rate,
177
+ nominalRate: baseRate,
178
+ standardRate: card.base_rate,
179
+ intro: introFor(null),
180
+ rate: baseRate,
174
181
  cap: null,
175
182
  afterCap: null,
176
183
  };
@@ -187,14 +194,19 @@ export function evaluateCard(card, held, ctx) {
187
194
  else
188
195
  applying.push(rule);
189
196
  }
190
- const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
197
+ const uncapped = Math.max(baseRate, ...applying.filter((r) => r.cap === undefined).map(rateOf));
191
198
  for (const rule of applying) {
192
199
  const cap = capState(rule, card, held, ctx.date);
193
- const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
200
+ const rate = rateOf(rule);
201
+ const afterCap = cap
202
+ ? Math.max(withIntro(cap.cap.then_rate, rule.id, windows, true), uncapped)
203
+ : null;
194
204
  const earning = {
195
205
  rule,
196
- nominalRate: rule.rate,
197
- rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
206
+ nominalRate: rate,
207
+ standardRate: rule.rate,
208
+ intro: introFor(rule.id),
209
+ rate: rateAfterCap(rate, cap, afterCap ?? rate, ctx.amount),
198
210
  cap,
199
211
  afterCap,
200
212
  };
@@ -206,7 +218,7 @@ export function evaluateCard(card, held, ctx) {
206
218
  return {
207
219
  best,
208
220
  nominal,
209
- needsActivation: blocked.filter((r) => r.rate > best.rate),
221
+ needsActivation: blocked.filter((r) => rateOf(r) > best.rate),
210
222
  staleChoice,
211
223
  };
212
224
  }
@@ -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
+ }
@@ -1,13 +1,14 @@
1
1
  import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { openedOn } from "./intro.js";
4
5
  import { membershipName } from "./memberships.js";
5
6
  import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
6
7
  import { daysBetween } from "./periods.js";
7
8
  import { ruleWhat } from "./rule-text.js";
8
9
  import { rotationGap } from "./rotation.js";
9
10
  import { EPSILON, isClose } from "./compare.js";
10
- import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
11
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
11
12
  import { cardValuation } from "./valuation.js";
12
13
  export const STALE_AFTER_DAYS = 180;
13
14
  // The engine only tells the U.S. from everywhere else.
@@ -79,6 +80,18 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
79
80
  (bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
80
81
  // An unknown fee isn't guessed: it's warned about instead.
81
82
  const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
83
+ const fresh = card.intro_rates.length > 0 && openedOn(held) === null
84
+ ? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
85
+ : null;
86
+ const ifNew = fresh && fresh.rate > evaluation.best.rate + EPSILON
87
+ ? {
88
+ rate: fresh.nominalRate,
89
+ returnPct: (perDollar - evaluation.best.rate + fresh.rate) *
90
+ valuation.centsPerUnit -
91
+ fee,
92
+ days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
93
+ }
94
+ : null;
82
95
  entries.push({
83
96
  card,
84
97
  held,
@@ -90,6 +103,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
90
103
  effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
91
104
  rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
92
105
  upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
106
+ ifNew,
93
107
  });
94
108
  }
95
109
  entries.sort(compareEntries);
@@ -105,7 +119,12 @@ function reasoningFor(catalog, wallet, entry, amount) {
105
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}`);
106
120
  }
107
121
  else {
108
- lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
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}`);
109
128
  }
110
129
  const cap = best.cap;
111
130
  if (cap && amount !== null && amount > cap.remaining) {
@@ -133,7 +152,7 @@ function warningsFor(catalog, entry, purchase) {
133
152
  const { best, nominal } = evaluation;
134
153
  const warnings = [];
135
154
  if (entry.fee > 0)
136
- warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
155
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
137
156
  if (isForeign(purchase) && card.foreign_transaction_fee === null)
138
157
  warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
139
158
  const capLabel = (e) => e.cap
@@ -220,7 +239,7 @@ function tieNote(entries) {
220
239
  tied.some((e) => e.evaluation.best.cap !== null)
221
240
  ? 'because its rate has no cap'
222
241
  : 'by a fixed order; either works';
223
- return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
242
+ return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
224
243
  }
225
244
  // Finds the closest point inside a currency's value range where the winner
226
245
  // stops winning, varying one currency at a time. Returns are linear in the
@@ -299,6 +318,16 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
299
318
  assumptions.push(values);
300
319
  if (isForeign(purchase))
301
320
  assumptions.push(FOREIGN_ASSUMPTION);
321
+ const winner = entries[0];
322
+ for (const e of entries) {
323
+ if (!e.ifNew || !winner)
324
+ continue;
325
+ if (e !== winner &&
326
+ e.ifNew.returnPct <= winner.effectiveReturnPct + EPSILON)
327
+ continue;
328
+ const currency = e.valuation.currency;
329
+ assumptions.push(introUnknownText(displayName(e), earnText(catalog, e.ifNew.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency), e.ifNew.days));
330
+ }
302
331
  for (const e of entries)
303
332
  for (const hit of e.milestones)
304
333
  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.`);
@@ -338,9 +367,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
338
367
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
339
368
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
340
369
  if (alt.card.id !== winner.card.id)
341
- warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
370
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.rateReturnPct)}).`);
342
371
  else if (twin)
343
- warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
372
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.rateReturnPct)}).`);
344
373
  }
345
374
  }
346
375
  if (!winner) {
@@ -359,7 +388,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
359
388
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
360
389
  : undefined;
361
390
  if (alt)
362
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
391
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
363
392
  }
364
393
  else if (merchant.accepts === null &&
365
394
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -384,7 +413,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
384
413
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
385
414
  .entries[0];
386
415
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
387
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
416
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
388
417
  }
389
418
  }
390
419
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -400,7 +429,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
400
429
  const bonus = open && (open.behind || ties) ? open : null;
401
430
  const best = winner?.effectiveReturnPct ?? 0;
402
431
  if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
403
- notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
432
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
404
433
  }
405
434
  const chosen = bonus?.entry ?? winner;
406
435
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -417,7 +446,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
417
446
  const daysLeft = status.daysLeft ?? 0;
418
447
  const costHere = ties || top.earned === null || first.earned === null
419
448
  ? null
420
- : roundCents(first.earned - top.earned);
449
+ : roundCents(first.earned +
450
+ (first.milestoneBonus?.dollars ?? 0) -
451
+ (top.earned + (top.milestoneBonus?.dollars ?? 0)));
421
452
  bonusResult = {
422
453
  cardId: top.cardId,
423
454
  checkoutLabel: top.checkoutLabel,
@@ -29,6 +29,7 @@ export function capBooking(catalog, wallet, purchase, merchant = resolveMerchant
29
29
  capKey: cap.key,
30
30
  ruleId: rule.id,
31
31
  period: cap.period,
32
+ periodKind: cap.cap.period,
32
33
  capAmount: cap.cap.amount,
33
34
  before,
34
35
  after: roundCents(before + purchase.amount),
@@ -90,6 +91,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
90
91
  cardId: card.id,
91
92
  milestoneId: p.milestone.id,
92
93
  period: p.period,
94
+ periodKind: p.milestone.period,
93
95
  spend: p.milestone.spend,
94
96
  before: p.spent,
95
97
  after: roundCents(Math.max(0, p.spent + purchase.amount)),
@@ -1,5 +1,5 @@
1
1
  import { findMerchant } from "../merchants/resolve.js";
2
- import { categoryPhrase, choiceName, listed, words } from "./text.js";
2
+ import { categoryPhrase, choiceName, earnText, introPeriod, listed, num, words, } from "./text.js";
3
3
  // What a rule or a pick covers, in words, for answers and wallet lists.
4
4
  // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
5
5
  // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
@@ -57,3 +57,17 @@ export function ruleParts(catalog, rule) {
57
57
  export function ruleWhat(catalog, rule) {
58
58
  return listed(ruleParts(catalog, rule));
59
59
  }
60
+ // "double every rate for the first year", "an extra 3% cash back on your 3%
61
+ // picks for the first year".
62
+ export function introWhat(catalog, card, intro) {
63
+ const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
64
+ const on = intro.rules === undefined
65
+ ? 'every rate'
66
+ : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
67
+ const change = intro.multiply === undefined
68
+ ? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
69
+ : intro.multiply === 2
70
+ ? `double ${on}`
71
+ : `${num(intro.multiply)} times ${on}`;
72
+ return `${change} for the ${introPeriod(intro.days)}`;
73
+ }
@@ -3,6 +3,7 @@ import { membership, membershipName, sameGroup } from "./memberships.js";
3
3
  import { milestoneWhat } from "./milestones.js";
4
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { introWhat } from "./rule-text.js";
6
7
  import { idList, listed } from "./text.js";
7
8
  // Rules that start within this many days are worth activating now.
8
9
  const ACTIVATION_LOOKAHEAD_DAYS = 45;
@@ -105,6 +106,11 @@ export function cardInfo(catalog, card, wallet, today) {
105
106
  rewards: m.rewards ?? [],
106
107
  text: milestoneWhat(m).replace(/^the /, ''),
107
108
  })),
109
+ introRates: card.intro_rates.map((i) => ({
110
+ id: i.id,
111
+ days: i.days,
112
+ text: introWhat(catalog, card, i),
113
+ })),
108
114
  };
109
115
  }
110
116
  // Every word of the query has to match a word of the card's id, name or
@@ -63,6 +63,15 @@ export function categoryPhrase(catalog, id) {
63
63
  ? name
64
64
  : name.charAt(0).toLowerCase() + name.slice(1);
65
65
  }
66
+ // A category bought as a service reads "on" (streaming services); its own
67
+ // preposition or a parent's says so. Everything else reads "at".
68
+ export function categoryReadsOn(catalog, id) {
69
+ const category = catalog.categories.find((c) => c.id === id);
70
+ if (category?.preposition === 'on')
71
+ return true;
72
+ const dot = id.lastIndexOf('.');
73
+ return dot !== -1 && categoryReadsOn(catalog, id.slice(0, dot));
74
+ }
66
75
  export function earnText(catalog, rate, currency) {
67
76
  return currency === 'cash'
68
77
  ? `${pct(rate)} cash back`
@@ -122,6 +131,9 @@ export const CAP_PERIODS = {
122
131
  calendar_year: 'a calendar year',
123
132
  cardmember_year: 'a cardmember year',
124
133
  };
134
+ // "for 2026-Q4", or "this cardmember year": a cardmember year's key is the
135
+ // year it started, which reads as the calendar year.
136
+ export const periodPhrase = (kind, key) => kind === 'cardmember_year' ? 'this cardmember year' : `for ${key}`;
125
137
  export const CAP_ADJECTIVES = {
126
138
  month: 'monthly',
127
139
  quarter: 'quarterly',
@@ -139,3 +151,14 @@ export const PICK_PERIODS = {
139
151
  calendar_year: 'each year',
140
152
  until_changed: 'until you change it',
141
153
  };
154
+ // Intro rates (a card's first 365 days): "first year", "first 90 days".
155
+ export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
156
+ export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
157
+ // Asking for an opening date: "if it opened in the last year".
158
+ export const introSince = (days) => days === 365 ? 'in the last year' : `in the last ${String(days)} days`;
159
+ // "6% cash back in your first year, through Mar 3, 2027; 3% cash back after
160
+ // that."
161
+ export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
162
+ // For a card whose opening date isn't known: it's never assumed to be new.
163
+ export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
164
+ export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
@@ -22,6 +22,8 @@ function isAnniversary(text) {
22
22
  const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
23
23
  return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
24
24
  }
25
+ const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
26
+ new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
25
27
  function pickKey(card, today) {
26
28
  const period = choicePeriod(card);
27
29
  return period === null ? null : periodKey(period, today, null);
@@ -44,6 +46,15 @@ export function cardFields(card, held, input, today) {
44
46
  }
45
47
  fields.anniversary = input.anniversary;
46
48
  }
49
+ if (input.opened === null)
50
+ fields.opened = null;
51
+ else if (input.opened !== undefined) {
52
+ if (!isDate(input.opened))
53
+ problems.push(`"${input.opened}" isn't a date. Use YYYY-MM-DD.`);
54
+ else if (input.opened > today)
55
+ problems.push("The account's opening date is in the future.");
56
+ fields.opened = input.opened;
57
+ }
47
58
  if (input.activated !== undefined) {
48
59
  const needing = card.rules
49
60
  .filter((r) => r.requires?.activation)
@@ -142,8 +153,6 @@ export function cardFields(card, held, input, today) {
142
153
  fields.bonus = null;
143
154
  else if (input.bonus !== undefined) {
144
155
  const b = input.bonus;
145
- const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
146
- new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
147
156
  if (!isDate(b.opened))
148
157
  problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
149
158
  else if (b.opened > today)
@@ -177,6 +186,12 @@ export function cardFields(card, held, input, today) {
177
186
  ...(dollars === undefined ? {} : { dollars }),
178
187
  };
179
188
  }
189
+ const opened = fields.opened === undefined ? (held?.opened ?? null) : fields.opened;
190
+ const bonusOpened = fields.bonus === undefined
191
+ ? (held?.bonus?.opened ?? null)
192
+ : fields.bonus?.opened;
193
+ if (opened !== null && bonusOpened != null && opened !== bonusOpened)
194
+ problems.push(`The card's opening date (${opened}) and its welcome bonus's (${bonusOpened}) differ; give the same date for both.`);
180
195
  return problems.length > 0 ? problems.join('\n') : fields;
181
196
  }
182
197
  const newHolding = (id) => ({
@@ -184,8 +199,10 @@ const newHolding = (id) => ({
184
199
  cap_usage: {},
185
200
  activated: [],
186
201
  });
187
- const withFields = ({ bonus, checkout_label, ...held }, f) => ({
202
+ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
188
203
  ...held,
204
+ ...(opened !== undefined && f.opened === undefined ? { opened } : {}),
205
+ ...(f.opened ? { opened: f.opened } : {}),
189
206
  ...(bonus && f.bonus === undefined ? { bonus } : {}),
190
207
  ...(checkout_label !== undefined && f.checkout_label === undefined
191
208
  ? { checkout_label }