earnrate 0.2.11 → 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 (126) hide show
  1. package/README.md +21 -9
  2. package/data/CHANGELOG.md +110 -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 +2 -0
  11. package/data/cards/amex-delta-skymiles-platinum.yaml +2 -0
  12. package/data/cards/amex-delta-skymiles-reserve.yaml +2 -0
  13. package/data/cards/amex-gold.yaml +1 -0
  14. package/data/cards/amex-hilton-honors-aspire.yaml +2 -0
  15. package/data/cards/amex-hilton-honors-business.yaml +93 -0
  16. package/data/cards/amex-hilton-honors-surpass.yaml +2 -0
  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 +2 -0
  20. package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
  21. package/data/cards/amex-platinum.yaml +1 -0
  22. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
  23. package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
  24. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -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 +2 -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 +2 -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 +1 -0
  53. package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -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 +1 -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 +1 -0
  63. package/data/cards/chase-united-club-business.yaml +1 -0
  64. package/data/cards/chase-united-club.yaml +1 -0
  65. package/data/cards/chase-united-explorer.yaml +1 -0
  66. package/data/cards/chase-united-gateway.yaml +1 -0
  67. package/data/cards/chase-united-quest.yaml +1 -0
  68. package/data/cards/citi-aadvantage-business.yaml +2 -0
  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 +2 -0
  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 +22 -3
  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 +34 -0
  96. package/data/issuers.yaml +22 -1
  97. package/data/memberships.yaml +10 -0
  98. package/data/merchants/overrides.yaml +499 -1
  99. package/data/merchants/seed.yaml +843 -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/checklist.js +14 -3
  105. package/dist/engine/evaluate.js +19 -7
  106. package/dist/engine/intro.js +36 -0
  107. package/dist/engine/recommend.js +31 -2
  108. package/dist/engine/record.js +2 -0
  109. package/dist/engine/rule-text.js +15 -1
  110. package/dist/engine/setup.js +6 -0
  111. package/dist/engine/text.js +23 -0
  112. package/dist/engine/wallet-change.js +20 -3
  113. package/dist/engine/wallet-summary.js +24 -4
  114. package/dist/format/card-art.js +21 -0
  115. package/dist/format/cards.js +1 -0
  116. package/dist/format/wallet-yaml.js +1 -0
  117. package/dist/format/wallet.js +10 -4
  118. package/dist/import/analyze.js +1 -0
  119. package/dist/mcp/inputs.js +3 -0
  120. package/dist/mcp/schemas.js +17 -0
  121. package/dist/mcp/tools/account.js +3 -1
  122. package/dist/mcp/tools/wallet.js +6 -2
  123. package/dist/schema/card.js +23 -0
  124. package/dist/schema/issuer.js +4 -0
  125. package/dist/schema/wallet.js +3 -0
  126. 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',
@@ -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) {
@@ -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.`);
@@ -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 }
@@ -1,13 +1,14 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
3
+ import { introUntil, introWindows, openedOn } from "./intro.js";
3
4
  import { membershipName } from "./memberships.js";
4
5
  import { milestoneSummaries } from "./milestones.js";
5
- import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
6
+ import { optionName, ruleParts } from "./rule-text.js";
6
7
  import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
7
8
  import { STALE_AFTER_DAYS } from "./recommend.js";
8
9
  import { rotationGap } from "./rotation.js";
9
10
  import { activationRules } from "./setup.js";
10
- import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
11
+ import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
11
12
  import { cardValuation, currencyValuation } from "./valuation.js";
12
13
  const matchKeys = (rule) => rule.match.choice !== undefined
13
14
  ? []
@@ -98,8 +99,13 @@ export function activationsDue(catalog, card, held, today) {
98
99
  seen.add(g);
99
100
  const rules = card.rules.filter((r) => group.includes(r.id));
100
101
  const first = rules[0];
102
+ const categories = rules.flatMap((r) => r.match.categories ?? []);
103
+ const servicesOnly = categories.length > 0 &&
104
+ rules.every((r) => (r.match.merchants ?? []).length === 0) &&
105
+ categories.every((c) => categoryReadsOn(catalog, c));
101
106
  out.push({
102
- what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
107
+ what: listed([...new Set(rules.flatMap((r) => ruleParts(catalog, r)))]),
108
+ preposition: servicesOnly ? 'on' : 'at',
103
109
  from: first?.valid_from ?? null,
104
110
  to: first?.valid_to ?? null,
105
111
  activateBy: first?.activate_by ?? null,
@@ -144,7 +150,7 @@ function capSummaries(catalog, card, held, today) {
144
150
  const shared = card.caps.find((c) => c.id === key);
145
151
  const label = shared?.rotating
146
152
  ? 'the rotating bonus categories'
147
- : [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
153
+ : listed([...new Set(covered.flatMap((r) => ruleParts(catalog, r)))]) ||
148
154
  words(key);
149
155
  return {
150
156
  key,
@@ -211,6 +217,8 @@ export function walletSummary(catalog, wallet, today) {
211
217
  activated: held.activated,
212
218
  toActivate: toActivate(catalog, card, held, today),
213
219
  anniversary: held.anniversary ?? null,
220
+ opened: openedOn(held),
221
+ intro: introSummary(card, held, today),
214
222
  });
215
223
  }
216
224
  return {
@@ -221,6 +229,18 @@ export function walletSummary(catalog, wallet, today) {
221
229
  cards,
222
230
  };
223
231
  }
232
+ function introSummary(card, held, today) {
233
+ const [first] = card.intro_rates;
234
+ if (!first)
235
+ return null;
236
+ const opened = openedOn(held);
237
+ if (opened === null)
238
+ return { state: 'no-date', until: null, days: first.days };
239
+ const [active] = introWindows(card, held, today);
240
+ if (active)
241
+ return { state: 'active', until: active.until, days: active.intro.days };
242
+ return { state: 'ended', until: introUntil(first, opened), days: first.days };
243
+ }
224
244
  function pointValues(catalog, wallet) {
225
245
  return Object.keys(wallet.valuations)
226
246
  .filter((id) => catalog.currencies.some((c) => c.id === id))
@@ -0,0 +1,21 @@
1
+ const FALLBACK = '#5c5650';
2
+ export function cardColor(catalog, cardId) {
3
+ const card = catalog.cards.find((c) => c.id === cardId);
4
+ if (card?.color)
5
+ return card.color;
6
+ return catalog.issuers.find((i) => i.id === card?.issuer)?.color ?? FALLBACK;
7
+ }
8
+ // Relative luminance, to keep the chip and edge visible on light cards.
9
+ function light(hex) {
10
+ const [r, g, b] = [1, 3, 5].map((i) => {
11
+ const c = parseInt(hex.slice(i, i + 2), 16) / 255;
12
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
13
+ });
14
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.35;
15
+ }
16
+ // 86 x 54 is a card's 85.6 x 54 mm; `width` scales it.
17
+ export function cardArtSvg(color, width = 48) {
18
+ const height = Math.round((width * 54) / 86);
19
+ const edge = light(color) ? 'rgba(0,0,0,0.18)' : 'rgba(255,255,255,0.14)';
20
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 86 54" width="${String(width)}" height="${String(height)}" aria-hidden="true" focusable="false"><rect x="0.5" y="0.5" width="85" height="53" rx="5" fill="${color}" stroke="${edge}"/><path d="M0 40 L56 0 H80 Q85.5 0 85.5 5 V8 L20 54 H5 Q0 54 0 49 Z" fill="#fff" opacity="0.07"/><rect x="10" y="17" width="14" height="11" rx="2" fill="#d9c08a" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><path d="M10 22.5 H24 M17 17 V28" stroke="rgba(0,0,0,0.25)" stroke-width="0.6"/><rect x="10" y="40" width="30" height="3" rx="1.5" fill="${light(color) ? '#000' : '#fff'}" opacity="0.28"/></svg>`;
21
+ }
@@ -20,6 +20,7 @@ export function formatCardInfo(card) {
20
20
  : []),
21
21
  ...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
22
22
  ...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
23
+ ...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
23
24
  ...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
24
25
  foreignFeeText(card.foreignTransactionFee).toLowerCase(),
25
26
  ];
@@ -19,6 +19,7 @@ function compact(wallet) {
19
19
  ? {}
20
20
  : { checkout_label: c.checkout_label }),
21
21
  ...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
22
+ ...(c.opened === undefined ? {} : { opened: c.opened }),
22
23
  ...(Object.keys(c.cap_usage).length > 0
23
24
  ? { cap_usage: c.cap_usage }
24
25
  : {}),