earnrate 0.2.14 → 0.2.16
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.
- package/README.md +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/args.js +30 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/run.js +10 -1
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +13 -10
- package/dist/engine/checklist.js +5 -10
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +7 -3
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +3 -7
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +32 -23
- package/dist/format/card-text.js +6 -5
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +11 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +50 -42
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +205 -148
- package/dist/mcp/wallet-api.js +78 -0
- package/dist/schema/card.js +6 -10
- package/package.json +2 -1
package/dist/engine/explore.js
CHANGED
|
@@ -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 {
|
|
11
|
+
import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, capSentence, } from "./text.js";
|
|
12
12
|
import { cardValuation } from "./valuation.js";
|
|
13
13
|
import { EPSILON } from "./compare.js";
|
|
14
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -117,7 +117,7 @@ function conditionsText(catalog, card, r) {
|
|
|
117
117
|
if (r.membership !== null)
|
|
118
118
|
conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
|
|
119
119
|
if (r.cap) {
|
|
120
|
-
conditions.push(
|
|
120
|
+
conditions.push(`${capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency))}.`);
|
|
121
121
|
}
|
|
122
122
|
return conditions;
|
|
123
123
|
}
|
|
@@ -223,6 +223,10 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
223
223
|
...membershipNeeded(catalog, card, members),
|
|
224
224
|
],
|
|
225
225
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
226
|
+
needsMembership: card.requires_membership !== undefined &&
|
|
227
|
+
!members.has(card.requires_membership)
|
|
228
|
+
? card.requires_membership
|
|
229
|
+
: null,
|
|
226
230
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
227
231
|
});
|
|
228
232
|
}
|
|
@@ -303,7 +307,7 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
|
|
|
303
307
|
function exploreWarnings(catalog, merchant) {
|
|
304
308
|
const warnings = [];
|
|
305
309
|
if (merchant.source === 'none') {
|
|
306
|
-
warnings.push(`
|
|
310
|
+
warnings.push(`earnrate doesn't know "${merchant.input}" yet, so only base rates apply. Try a category, or the name of a larger chain.`);
|
|
307
311
|
}
|
|
308
312
|
if (merchant.ambiguity) {
|
|
309
313
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
package/dist/engine/intro.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const t = new Date(`${date}T00:00:00Z`);
|
|
3
|
-
t.setUTCDate(t.getUTCDate() + days);
|
|
4
|
-
return t.toISOString().slice(0, 10);
|
|
5
|
-
}
|
|
1
|
+
import { addDays } from "./periods.js";
|
|
6
2
|
// Never assumed: a card without a date is ranked at its standard rates.
|
|
7
3
|
export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
8
4
|
// The opening day is day 1, so 365 days run through the day before the first
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import { money, words } from "./text.js";
|
|
1
|
+
import { listed, money, withArticle, words } from "./text.js";
|
|
2
2
|
export function membership(catalog, id) {
|
|
3
3
|
return catalog.memberships.find((m) => m.id === id);
|
|
4
4
|
}
|
|
5
5
|
export function membershipName(catalog, id) {
|
|
6
6
|
return membership(catalog, id)?.name ?? words(id);
|
|
7
7
|
}
|
|
8
|
-
// "a Costco membership", for a sentence.
|
|
9
|
-
function withArticle(name) {
|
|
10
|
-
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
|
-
}
|
|
12
8
|
// Tiers of one membership: holding one, the others in its group aren't
|
|
13
9
|
// missing (BofA Preferred Rewards).
|
|
14
10
|
export function sameGroup(catalog, a, b) {
|
|
@@ -54,7 +50,7 @@ export function membershipCosts(catalog, cards, paid) {
|
|
|
54
50
|
export function membershipText(catalog, id) {
|
|
55
51
|
const m = membership(catalog, id);
|
|
56
52
|
const name = withArticle(m?.name ?? words(id));
|
|
57
|
-
return m?.annual_fee === undefined
|
|
53
|
+
return m?.annual_fee === undefined || m.annual_fee === 0
|
|
58
54
|
? name
|
|
59
55
|
: `${name} (${money(m.annual_fee)} a year)`;
|
|
60
56
|
}
|
|
@@ -67,5 +63,5 @@ export function membershipNeeded(catalog, card, paid) {
|
|
|
67
63
|
}
|
|
68
64
|
export function membershipNote(catalog, m) {
|
|
69
65
|
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
70
|
-
return `${names
|
|
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.`;
|
|
71
67
|
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -2,17 +2,15 @@ import { bonusDollars } from "./bonus.js";
|
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
4
|
import { inEffect } from "./periods.js";
|
|
5
|
-
import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
|
|
5
|
+
import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
|
|
6
6
|
import { cardValuation } from "./valuation.js";
|
|
7
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
8
8
|
const held = wallet.cards
|
|
9
9
|
.filter((c) => notWith.includes(c.id))
|
|
10
|
-
.map((c) => c
|
|
11
|
-
catalog.cards.find((k) => k.id === c.id)?.name ??
|
|
12
|
-
c.id);
|
|
10
|
+
.map((c) => heldName(catalog, c));
|
|
13
11
|
return held.length === 0
|
|
14
12
|
? null
|
|
15
|
-
: `Likely not eligible: you hold ${held
|
|
13
|
+
: `Likely not eligible: you hold ${listed(held)}, and the issuer's terms rule out its cardholders.`;
|
|
16
14
|
}
|
|
17
15
|
export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
|
|
18
16
|
// "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
|
package/dist/engine/periods.js
CHANGED
|
@@ -23,6 +23,11 @@ export function periodKey(period, date, anniversary) {
|
|
|
23
23
|
export function daysBetween(from, to) {
|
|
24
24
|
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
|
|
25
25
|
}
|
|
26
|
+
export function addDays(date, days) {
|
|
27
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
28
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
29
|
+
return t.toISOString().slice(0, 10);
|
|
30
|
+
}
|
|
26
31
|
// The user's local calendar date, not UTC: a purchase at 9pm in Los Angeles
|
|
27
32
|
// belongs to that day.
|
|
28
33
|
export function localToday(now = new Date()) {
|
package/dist/engine/profiles.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listed } from "./text.js";
|
|
1
2
|
export const VALUATION_PROFILES = {
|
|
2
3
|
flat: { MR: 1.0, UR: 1.0 },
|
|
3
4
|
'points-heavy': { MR: 1.7, UR: 1.7 },
|
|
@@ -7,7 +8,7 @@ export function profileText(profile) {
|
|
|
7
8
|
const values = Object.entries(VALUATION_PROFILES[profile]);
|
|
8
9
|
const cents = [...new Set(values.map(([, v]) => v))];
|
|
9
10
|
return cents.length === 1
|
|
10
|
-
? `${values.map(([id]) => id)
|
|
11
|
+
? `${listed(values.map(([id]) => id))} at ${String(cents[0])}¢`
|
|
11
12
|
: values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
|
|
12
13
|
}
|
|
13
14
|
export function isValuationProfile(name) {
|
package/dist/engine/recommend.js
CHANGED
|
@@ -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, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.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";
|
|
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.
|
|
@@ -17,7 +17,7 @@ export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: e
|
|
|
17
17
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
18
18
|
// A fee that isn't on file counts as the usual 3%, so a card can't win
|
|
19
19
|
// abroad on a guess that it charges none.
|
|
20
|
-
|
|
20
|
+
const ASSUMED_FOREIGN_FEE = 3;
|
|
21
21
|
export const foreignFeeFor = (card, purchase) => isForeign(purchase)
|
|
22
22
|
? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
|
|
23
23
|
: 0;
|
|
@@ -149,7 +149,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
149
149
|
const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
|
|
150
150
|
if (best.rule) {
|
|
151
151
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
152
|
-
lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
152
|
+
lines.push(`${standard} on ${ruleWhat(catalog, best.rule, card)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
153
153
|
}
|
|
154
154
|
else {
|
|
155
155
|
lines.push(`${standard}, its base rate. No bonus category applies here.`);
|
|
@@ -169,10 +169,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
169
169
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
170
170
|
const heldPartners = wallet.cards
|
|
171
171
|
.filter((h) => partners.includes(h.id))
|
|
172
|
-
.map((h) => h
|
|
173
|
-
|
|
174
|
-
h.id);
|
|
175
|
-
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
172
|
+
.map((h) => heldName(catalog, h));
|
|
173
|
+
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${listed(heldPartners)}.`);
|
|
176
174
|
}
|
|
177
175
|
if (currency !== 'cash') {
|
|
178
176
|
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
@@ -196,17 +194,17 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
196
194
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
197
195
|
}
|
|
198
196
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
199
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
|
|
197
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)}: only ${money(remaining)} is left for ${period}.`);
|
|
200
198
|
}
|
|
201
199
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
202
|
-
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
|
|
200
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule, card)} for ${period}.`);
|
|
203
201
|
}
|
|
204
202
|
}
|
|
205
203
|
if (nominal.rule &&
|
|
206
204
|
nominal.cap &&
|
|
207
205
|
nominal !== best &&
|
|
208
206
|
nominal.cap.remaining === 0) {
|
|
209
|
-
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
207
|
+
warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule, card)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
|
|
210
208
|
}
|
|
211
209
|
if (best.rule?.unverified !== undefined) {
|
|
212
210
|
warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
|
|
@@ -275,7 +273,7 @@ function tieNote(entries) {
|
|
|
275
273
|
const first = tied[0];
|
|
276
274
|
if (!first)
|
|
277
275
|
return null;
|
|
278
|
-
const names = tied.map(displayName)
|
|
276
|
+
const names = listed(tied.map(displayName));
|
|
279
277
|
const verb = tied.length === 1 ? 'earns' : 'earn';
|
|
280
278
|
const why = winner.upside - first.upside > EPSILON
|
|
281
279
|
? 'because its points can be worth more'
|
|
@@ -285,11 +283,11 @@ function tieNote(entries) {
|
|
|
285
283
|
: 'by a fixed order; either works';
|
|
286
284
|
return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
287
285
|
}
|
|
286
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
287
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
288
288
|
// Finds the closest point inside a currency's value range where the winner
|
|
289
289
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
290
290
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
291
|
-
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
292
|
-
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
293
291
|
function sensitivity(catalog, entries) {
|
|
294
292
|
const [winner, ...rest] = entries;
|
|
295
293
|
if (!winner)
|
|
@@ -338,7 +336,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
338
336
|
const rule = e.evaluation.best.rule;
|
|
339
337
|
return rule &&
|
|
340
338
|
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
341
|
-
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
339
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule, e.card)})`]
|
|
342
340
|
: [];
|
|
343
341
|
});
|
|
344
342
|
assumptions.push(naming.length === 0
|
|
@@ -488,9 +486,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
488
486
|
const ties = !isBest && gap <= EPSILON;
|
|
489
487
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
490
488
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
489
|
+
const toGo = open
|
|
490
|
+
? `${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}`
|
|
491
|
+
: '';
|
|
492
|
+
if (open && isBest && !bonus)
|
|
493
|
+
notes.push(`${displayName(open.entry)} is the best card here anyway, and this counts toward its welcome bonus (${toGo}).`);
|
|
494
|
+
else if (open && !bonus && isClose(open.entry.effectiveReturnPct, best))
|
|
495
|
+
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 (${toGo}). You're on pace, so this uses the higher rate.`);
|
|
494
496
|
const chosen = bonus?.entry ?? winner;
|
|
495
497
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
496
498
|
const tie = bonus ? null : tieNote(entries);
|
package/dist/engine/record.js
CHANGED
|
@@ -80,7 +80,7 @@ export function applyBonusBooking(wallet, b) {
|
|
|
80
80
|
}
|
|
81
81
|
// Every purchase on a card counts toward each of its spend milestones. None
|
|
82
82
|
// for a cardmember-year milestone on a card with no anniversary.
|
|
83
|
-
|
|
83
|
+
function milestoneBookings(catalog, wallet, purchase) {
|
|
84
84
|
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
85
85
|
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
86
86
|
if (!held || !card)
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, PICK_PERIODS, 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.
|
|
@@ -22,51 +22,99 @@ export function optionName(catalog, card, choiceId, option) {
|
|
|
22
22
|
parts.push('online shopping');
|
|
23
23
|
return parts.length > 0 ? listed(parts) : words(option);
|
|
24
24
|
}
|
|
25
|
-
// What a rule covers, as a list of phrases.
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// What a rule covers, as a list of phrases. With the card, a pick reads
|
|
26
|
+
// "the 2 categories you pick each quarter" and a rule for every purchase reads
|
|
27
|
+
// "every purchase" when it's the card's only one.
|
|
28
|
+
export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
29
|
+
const m = rule.match;
|
|
30
|
+
if (m.any)
|
|
28
31
|
return [
|
|
29
32
|
rule.geography === 'foreign_only'
|
|
30
33
|
? 'purchases outside the U.S.'
|
|
31
34
|
: rule.requires?.payment !== undefined
|
|
32
35
|
? 'purchases'
|
|
33
|
-
:
|
|
36
|
+
: card?.rules.every((r) => r.match.any)
|
|
37
|
+
? 'every purchase'
|
|
38
|
+
: 'everything else',
|
|
34
39
|
];
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (m.choice !== undefined) {
|
|
41
|
+
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
42
|
+
if (!choice)
|
|
43
|
+
return [`your ${choiceName(m.choice)} picks`];
|
|
44
|
+
const which = choice.pick === 1
|
|
45
|
+
? 'the category'
|
|
46
|
+
: `the ${String(choice.pick)} categories`;
|
|
47
|
+
return [
|
|
48
|
+
`${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
const merchants = m.merchants ?? [];
|
|
52
|
+
const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
|
|
53
|
+
const merchantParts = (everyStore ? null : shortenedMerchants(catalog, merchants)) ??
|
|
54
|
+
merchants.map((id) => merchantName(catalog, id));
|
|
55
|
+
// A rule naming both matches a purchase that is both.
|
|
56
|
+
if (merchants.length > 0 && categories.length > 0)
|
|
57
|
+
return [`${listed(merchantParts)} when it codes as ${listed(categories)}`];
|
|
58
|
+
const parts = [...categories, ...(merchants.length > 0 ? merchantParts : [])];
|
|
59
|
+
if (parts.length > 0)
|
|
60
|
+
return parts;
|
|
61
|
+
return m.mccs?.length
|
|
62
|
+
? [`purchases coded MCC ${m.mccs.map(String).join(', ')}`]
|
|
63
|
+
: [words(rule.id)];
|
|
64
|
+
}
|
|
65
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
66
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
67
|
+
// Null when the list is read out in full.
|
|
68
|
+
function shortenedKind(catalog, merchants) {
|
|
40
69
|
const counts = new Map();
|
|
41
|
-
for (const
|
|
42
|
-
const category = findMerchant(catalog,
|
|
70
|
+
for (const id of merchants) {
|
|
71
|
+
const category = findMerchant(catalog, id)?.category;
|
|
43
72
|
if (category)
|
|
44
73
|
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
45
74
|
}
|
|
46
75
|
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
47
|
-
|
|
76
|
+
return merchants.length > 4 &&
|
|
48
77
|
kind !== undefined &&
|
|
49
78
|
count >= merchants.length * 0.75
|
|
50
|
-
?
|
|
51
|
-
:
|
|
52
|
-
const parts = [
|
|
53
|
-
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
54
|
-
...merchantParts,
|
|
55
|
-
];
|
|
56
|
-
return parts.length > 0 ? parts : [words(rule.id)];
|
|
79
|
+
? kind
|
|
80
|
+
: null;
|
|
57
81
|
}
|
|
58
|
-
export function
|
|
82
|
+
export function shortenedMerchants(catalog, merchants) {
|
|
83
|
+
const kind = shortenedKind(catalog, merchants);
|
|
84
|
+
return kind === null ? null : [`select ${categoryPhrase(catalog, kind)}`];
|
|
85
|
+
}
|
|
86
|
+
export function ruleWhat(catalog, rule, card, options = {}) {
|
|
59
87
|
const payment = rule.requires?.payment;
|
|
60
|
-
const what = listed(ruleParts(catalog, rule));
|
|
88
|
+
const what = listed(ruleParts(catalog, rule, card, options));
|
|
61
89
|
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
62
90
|
}
|
|
91
|
+
// "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
|
|
92
|
+
// category's own preposition, "at" for a store unless its data says "on" or
|
|
93
|
+
// it's a booking through a travel site ("on hotels through Amex Travel"), and
|
|
94
|
+
// a shortened list ("select streaming services") reads like its kind.
|
|
95
|
+
export function rulePreposition(catalog, rules) {
|
|
96
|
+
const readsOn = (r) => {
|
|
97
|
+
if (r.match.any === true || r.match.choice !== undefined)
|
|
98
|
+
return true;
|
|
99
|
+
const merchants = r.match.merchants ?? [];
|
|
100
|
+
const kind = shortenedKind(catalog, merchants);
|
|
101
|
+
return ((r.match.categories ?? []).every((c) => categoryReadsOn(catalog, c)) &&
|
|
102
|
+
(kind !== null
|
|
103
|
+
? categoryReadsOn(catalog, kind)
|
|
104
|
+
: merchants.every((id) => {
|
|
105
|
+
const merchant = findMerchant(catalog, id);
|
|
106
|
+
return (merchant?.preposition === 'on' || merchant?.kind === 'booking');
|
|
107
|
+
})));
|
|
108
|
+
};
|
|
109
|
+
return rules.length > 0 && rules.every(readsOn) ? 'on' : 'at';
|
|
110
|
+
}
|
|
63
111
|
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
64
112
|
// picks for the first year".
|
|
65
113
|
export function introWhat(catalog, card, intro) {
|
|
66
114
|
const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
|
|
67
115
|
const on = intro.rules === undefined
|
|
68
116
|
? 'every rate'
|
|
69
|
-
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
|
|
117
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r, card)))].join('; ');
|
|
70
118
|
const change = intro.multiply === undefined
|
|
71
119
|
? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
|
|
72
120
|
: intro.multiply === 2
|
package/dist/engine/setup.js
CHANGED
|
@@ -77,6 +77,10 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
77
77
|
network: card.network,
|
|
78
78
|
currency: card.currency,
|
|
79
79
|
annualFee: card.annual_fee ?? null,
|
|
80
|
+
firstYearFee: card.first_year_fee !== undefined &&
|
|
81
|
+
card.first_year_fee !== card.annual_fee
|
|
82
|
+
? card.first_year_fee
|
|
83
|
+
: null,
|
|
80
84
|
foreignTransactionFee: card.foreign_transaction_fee,
|
|
81
85
|
business: card.business,
|
|
82
86
|
onlyAt: card.only_at ?? null,
|
package/dist/engine/text.js
CHANGED
|
@@ -48,6 +48,11 @@ export function feeText(fees) {
|
|
|
48
48
|
return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
|
|
49
49
|
return `${money(annualFee)} annual fee`;
|
|
50
50
|
}
|
|
51
|
+
export function heldName(catalog, held) {
|
|
52
|
+
return (held.checkout_label ??
|
|
53
|
+
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
54
|
+
held.id);
|
|
55
|
+
}
|
|
51
56
|
export function currencyName(catalog, id) {
|
|
52
57
|
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
53
58
|
}
|
|
@@ -129,6 +134,18 @@ export function paymentNote(cards) {
|
|
|
129
134
|
}
|
|
130
135
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
131
136
|
// list's: those lists use commas only, with those names last.
|
|
137
|
+
// "Up to $1,500 of spending a quarter, then 1% cash back"
|
|
138
|
+
export const capSentence = (cap, then, of = 'spending', aside = '') => `Up to ${wholeDollars(cap.amount)} of ${of} ${CAP_PERIODS[cap.period]}${aside}, then ${then}`;
|
|
139
|
+
// "a Costco membership", "an Instacart credit", "an IHG card", "a Uber credit":
|
|
140
|
+
// by sound, so a leading "you" or "won" takes "a" and a letter said with a
|
|
141
|
+
// vowel sound ("an REI") takes "an".
|
|
142
|
+
export function withArticle(name) {
|
|
143
|
+
const initialism = /^[A-Z][A-Z&.-]{1,5}(?![a-z])/.test(name);
|
|
144
|
+
const an = initialism
|
|
145
|
+
? /^[AEFHILMNORSX]/.test(name)
|
|
146
|
+
: /^[aeiou]/i.test(name) && !/^(u[bnst]|u\.s\.|eu|one\b|once\b)/i.test(name);
|
|
147
|
+
return `${an ? 'an' : 'a'} ${name}`;
|
|
148
|
+
}
|
|
132
149
|
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
133
150
|
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
134
151
|
export const listed = (parts, joiner = 'and') => {
|
|
@@ -168,7 +185,7 @@ export const CAP_ADJECTIVES = {
|
|
|
168
185
|
calendar_year: 'calendar-year',
|
|
169
186
|
cardmember_year: 'cardmember-year',
|
|
170
187
|
};
|
|
171
|
-
// "the $15,000 calendar-year spend", for a spend
|
|
188
|
+
// "the $15,000 calendar-year spend", for a spend bonus.
|
|
172
189
|
export const MILESTONE_PERIODS = {
|
|
173
190
|
calendar_year: 'calendar-year',
|
|
174
191
|
cardmember_year: 'cardmember-year',
|
|
@@ -190,7 +207,7 @@ export const introRateText = (earn, standard, days, until) => `${earn} in your $
|
|
|
190
207
|
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
191
208
|
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)}.`;
|
|
192
209
|
export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
193
|
-
//
|
|
210
|
+
// Statement credits. Always dollars, never a percentage.
|
|
194
211
|
export const CREDIT_PERIODS = {
|
|
195
212
|
month: 'each month',
|
|
196
213
|
quarter: 'each quarter',
|
|
@@ -207,7 +224,7 @@ export const CREDIT_THIS_PERIOD = {
|
|
|
207
224
|
};
|
|
208
225
|
export const ENROLL_FIRST = 'enroll first';
|
|
209
226
|
// The group of cards with a credit at the store, shown before the ranking.
|
|
210
|
-
export const CREDIT_GROUP = 'With a
|
|
227
|
+
export const CREDIT_GROUP = 'With a statement credit';
|
|
211
228
|
// "$75 back each quarter"
|
|
212
229
|
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
213
230
|
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
@@ -218,9 +235,9 @@ export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_P
|
|
|
218
235
|
export function creditAnswerText(c, dollars) {
|
|
219
236
|
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
220
237
|
if (!c.enrolled)
|
|
221
|
-
return `Has
|
|
238
|
+
return `Has ${withArticle(credit)} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
222
239
|
if (c.left === null)
|
|
223
|
-
return `Has
|
|
240
|
+
return `Has ${withArticle(credit)} here; give the card's anniversary to track it.`;
|
|
224
241
|
if (c.left <= 0)
|
|
225
242
|
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
226
243
|
if (dollars === null)
|
|
@@ -249,4 +266,4 @@ export const enrollCreditText = (card, names) => names.length === 1
|
|
|
249
266
|
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
250
267
|
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
251
268
|
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
252
|
-
export const CREDITS_LEFT_OUT = '
|
|
269
|
+
export const CREDITS_LEFT_OUT = 'Statement credits (dollars back at a named store) are left out.';
|
|
@@ -134,15 +134,15 @@ export function cardFields(card, held, input, today) {
|
|
|
134
134
|
const milestone = card.milestones.find((m) => m.id === id);
|
|
135
135
|
if (!milestone) {
|
|
136
136
|
problems.push(card.milestones.length > 0
|
|
137
|
-
? `${card.id} has no spend
|
|
138
|
-
: `${card.id} has no spend
|
|
137
|
+
? `${card.id} has no spend bonus '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
|
|
138
|
+
: `${card.id} has no spend bonuses.`);
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
141
|
if (!(spent >= 0))
|
|
142
|
-
problems.push('Spending toward a
|
|
142
|
+
problems.push('Spending toward a spend bonus must be $0 or more.');
|
|
143
143
|
const period = periodKey(milestone.period, today, anniversary);
|
|
144
144
|
if (period === null) {
|
|
145
|
-
problems.push(`The '${id}'
|
|
145
|
+
problems.push(`The '${id}' spend bonus resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
146
146
|
continue;
|
|
147
147
|
}
|
|
148
148
|
usage[id] = { period, spent };
|
|
@@ -169,7 +169,7 @@ export function cardFields(card, held, input, today) {
|
|
|
169
169
|
if (!credit) {
|
|
170
170
|
problems.push(card.credits.length > 0
|
|
171
171
|
? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
|
|
172
|
-
: `${card.id} has no
|
|
172
|
+
: `${card.id} has no statement credits.`);
|
|
173
173
|
continue;
|
|
174
174
|
}
|
|
175
175
|
if (!(used >= 0 && used <= credit.amount))
|
|
@@ -4,12 +4,12 @@ import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
6
6
|
import { creditStates } from "./credits.js";
|
|
7
|
-
import { creditWhere, optionName, ruleParts } from "./rule-text.js";
|
|
7
|
+
import { creditWhere, optionName, ruleParts, rulePreposition, } from "./rule-text.js";
|
|
8
8
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
9
9
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { activationRules } from "./setup.js";
|
|
12
|
-
import { CAP_PERIODS,
|
|
12
|
+
import { CAP_PERIODS, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
|
|
13
13
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
14
14
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
15
15
|
? []
|
|
@@ -30,11 +30,9 @@ function capLimit(card, shown, rule) {
|
|
|
30
30
|
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
31
31
|
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
37
|
-
// a line.
|
|
33
|
+
// Best first. Leaves out rates behind a membership the wallet lacks and a
|
|
34
|
+
// lower tier an uncapped, always-on higher rate fully covers (Prime Visa's 3%
|
|
35
|
+
// without Prime, once Prime is held). Rules at the same rate share a line.
|
|
38
36
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
39
37
|
const shown = card.rules
|
|
40
38
|
.filter((r) => inEffect(r, today) &&
|
|
@@ -65,22 +63,18 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
65
63
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
66
64
|
const payment = r.requires?.payment;
|
|
67
65
|
const key = [
|
|
68
|
-
|
|
66
|
+
earnText(catalog, r.rate, currency),
|
|
69
67
|
capLimit(card, shown, r),
|
|
70
68
|
pending ? ', once activated' : '',
|
|
71
69
|
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
72
70
|
].join('|');
|
|
73
|
-
|
|
74
|
-
for (const part of ruleParts(catalog, r))
|
|
75
|
-
if (!whats.includes(part))
|
|
76
|
-
whats.push(part);
|
|
77
|
-
lines.set(key, whats);
|
|
71
|
+
lines.set(key, [...(lines.get(key) ?? []), r]);
|
|
78
72
|
}
|
|
79
73
|
return [
|
|
80
|
-
...[...lines].map(([key,
|
|
74
|
+
...[...lines].map(([key, rules]) => {
|
|
81
75
|
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
82
76
|
const after = `${limit}${activate}`;
|
|
83
|
-
return `${head} ${
|
|
77
|
+
return `${head} ${wherePhrase(catalog, card, rules)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
84
78
|
}),
|
|
85
79
|
...(shown.some((r) => r.match.any &&
|
|
86
80
|
r.geography !== 'foreign_only' &&
|
|
@@ -92,6 +86,22 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
92
86
|
}
|
|
93
87
|
// Bonuses current or starting soon that the card hasn't activated, one per
|
|
94
88
|
// activation group (one activation covers a quarter's categories).
|
|
89
|
+
// "at gas stations and restaurants, and on transit": each rule's parts after
|
|
90
|
+
// its own preposition, the "at" ones first.
|
|
91
|
+
export function wherePhrase(catalog, card, rules) {
|
|
92
|
+
const parts = { at: [], on: [] };
|
|
93
|
+
for (const r of rules) {
|
|
94
|
+
const list = parts[rulePreposition(catalog, [r])];
|
|
95
|
+
for (const part of ruleParts(catalog, r, card))
|
|
96
|
+
if (!list.includes(part))
|
|
97
|
+
list.push(part);
|
|
98
|
+
}
|
|
99
|
+
const { at, on } = parts;
|
|
100
|
+
return [
|
|
101
|
+
...(at.length > 0 ? [`at ${listed(at)}`] : []),
|
|
102
|
+
...(on.length > 0 ? [`on ${listed(on)}`] : []),
|
|
103
|
+
].join(at.length > 1 ? ', and ' : ' and ');
|
|
104
|
+
}
|
|
95
105
|
export function activationsDue(catalog, card, held, today) {
|
|
96
106
|
const seen = new Set();
|
|
97
107
|
const out = [];
|
|
@@ -103,13 +113,11 @@ export function activationsDue(catalog, card, held, today) {
|
|
|
103
113
|
seen.add(g);
|
|
104
114
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
105
115
|
const first = rules[0];
|
|
106
|
-
const categories = rules.flatMap((r) => r.match.categories ?? []);
|
|
107
|
-
const servicesOnly = categories.length > 0 &&
|
|
108
|
-
rules.every((r) => (r.match.merchants ?? []).length === 0) &&
|
|
109
|
-
categories.every((c) => categoryReadsOn(catalog, c));
|
|
110
116
|
out.push({
|
|
111
|
-
what: listed([
|
|
112
|
-
|
|
117
|
+
what: listed([
|
|
118
|
+
...new Set(rules.flatMap((r) => ruleParts(catalog, r, card))),
|
|
119
|
+
]),
|
|
120
|
+
where: wherePhrase(catalog, card, rules),
|
|
113
121
|
from: first?.valid_from ?? null,
|
|
114
122
|
to: first?.valid_to ?? null,
|
|
115
123
|
activateBy: first?.activate_by ?? null,
|
|
@@ -154,8 +162,9 @@ function capSummaries(catalog, card, held, today) {
|
|
|
154
162
|
const shared = card.caps.find((c) => c.id === key);
|
|
155
163
|
const label = shared?.rotating
|
|
156
164
|
? 'the rotating bonus categories'
|
|
157
|
-
: listed([
|
|
158
|
-
|
|
165
|
+
: listed([
|
|
166
|
+
...new Set(covered.flatMap((r) => ruleParts(catalog, r, card))),
|
|
167
|
+
]) || words(key);
|
|
159
168
|
return {
|
|
160
169
|
key,
|
|
161
170
|
label,
|