earnrate 0.2.8 → 0.2.10
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 +31 -5
- package/data/CHANGELOG.md +145 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +4 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -0
- package/data/cards/amex-blue-cash-preferred.yaml +38 -3
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/amex-platinum.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
- package/data/cards/bilt-obsidian.yaml +4 -0
- package/data/cards/capital-one-kohls-card.yaml +2 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
- package/data/cards/capital-one-savor.yaml +4 -0
- package/data/cards/capital-one-venture-x.yaml +4 -0
- package/data/cards/capital-one-venture.yaml +4 -0
- package/data/cards/chase-aeroplan.yaml +5 -0
- package/data/cards/chase-freedom-flex.yaml +4 -0
- package/data/cards/chase-freedom-unlimited.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards.yaml +57 -7
- package/data/cards/chase-ink-business-cash.yaml +4 -0
- package/data/cards/chase-ink-business-preferred.yaml +4 -0
- package/data/cards/chase-prime-visa.yaml +4 -0
- package/data/cards/chase-sapphire-preferred.yaml +4 -0
- package/data/cards/chase-sapphire-reserve.yaml +4 -0
- package/data/cards/chase-southwest-performance-business.yaml +4 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier-business.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/chase-united-business.yaml +5 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/chase-united-club.yaml +5 -0
- package/data/cards/chase-united-explorer.yaml +5 -0
- package/data/cards/chase-united-gateway.yaml +5 -0
- package/data/cards/chase-united-quest.yaml +30 -2
- package/data/cards/citi-aadvantage-business.yaml +4 -0
- package/data/cards/citi-aadvantage-executive.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +4 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
- package/data/cards/citi-custom-cash.yaml +2 -0
- package/data/cards/citi-double-cash.yaml +4 -0
- package/data/cards/citi-my-best-buy-visa.yaml +4 -0
- package/data/cards/citi-strata-elite.yaml +4 -0
- package/data/cards/citi-strata-premier.yaml +4 -0
- package/data/cards/sofi-unlimited-2.yaml +4 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
- package/data/cards/td-bank-target-circle-card.yaml +2 -0
- package/data/cards/usbank-cash-plus.yaml +5 -0
- package/data/cards/wells-fargo-active-cash.yaml +4 -0
- package/data/cards/wells-fargo-autograph.yaml +4 -0
- package/data/merchants/overrides.yaml +290 -0
- package/data/merchants/seed.yaml +1049 -2
- package/dist/cli/args.js +3 -6
- package/dist/cli/explore.js +10 -0
- package/dist/cli/import.js +18 -4
- package/dist/cli/run.js +3 -1
- package/dist/cli/wallet.js +5 -1
- package/dist/data/file-store.js +2 -2
- package/dist/data/integrity.js +17 -0
- package/dist/data/store.js +1 -1
- package/dist/data/wallet-edit.js +32 -18
- package/dist/engine/cheat-sheet.js +46 -2
- package/dist/engine/checklist.js +19 -8
- package/dist/engine/evaluate.js +2 -0
- package/dist/engine/explore.js +9 -5
- package/dist/engine/milestones.js +91 -0
- package/dist/engine/periods.js +29 -12
- package/dist/engine/recommend.js +68 -14
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +17 -5
- package/dist/engine/setup.js +13 -2
- package/dist/engine/simulate.js +25 -3
- package/dist/engine/text.js +11 -0
- package/dist/engine/wallet-change.js +67 -0
- package/dist/engine/wallet-summary.js +17 -5
- package/dist/format/cards.js +3 -1
- package/dist/format/recommendation.js +8 -2
- package/dist/format/wallet.js +22 -4
- package/dist/import/analyze.js +42 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/schemas.js +50 -2
- package/dist/mcp/server.js +17 -1
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +18 -10
- package/dist/schema/card.js +22 -1
- package/dist/schema/common.js +4 -0
- package/dist/schema/wallet.js +2 -0
- package/package.json +5 -3
package/dist/engine/periods.js
CHANGED
|
@@ -42,17 +42,34 @@ export function choicePeriod(card) {
|
|
|
42
42
|
// limit on that side.
|
|
43
43
|
export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
|
|
44
44
|
(window.valid_to === undefined || window.valid_to >= date);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
45
|
+
const utcDay = (year, month, day) => new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10);
|
|
46
|
+
// The last day of the period a key from periodKey names. A calendar year and
|
|
47
|
+
// a cardmember year share the key shape ("2026"), so the kind is needed: a
|
|
48
|
+
// cardmember year ends the day before the next anniversary. null for a
|
|
49
|
+
// cardmember year without an anniversary, or a key that doesn't fit the kind.
|
|
50
|
+
export function periodEnd(period, key, anniversary) {
|
|
51
|
+
switch (period) {
|
|
52
|
+
case 'month': {
|
|
53
|
+
const m = /^(\d{4})-(\d{2})$/.exec(key);
|
|
54
|
+
return m ? utcDay(Number(m[1]), Number(m[2]) + 1, 0) : null;
|
|
55
|
+
}
|
|
56
|
+
case 'quarter': {
|
|
57
|
+
const q = /^(\d{4})-Q([1-4])$/.exec(key);
|
|
58
|
+
return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
|
|
59
|
+
}
|
|
60
|
+
case 'calendar_year':
|
|
61
|
+
return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
|
|
62
|
+
case 'cardmember_year': {
|
|
63
|
+
const a = anniversary === null ? null : /^(\d{2})-(\d{2})$/.exec(anniversary);
|
|
64
|
+
if (!a || !/^\d{4}$/.test(key))
|
|
65
|
+
return null;
|
|
66
|
+
// Date.UTC rolls over; a parsed "2027-02-29" would be invalid.
|
|
67
|
+
return utcDay(Number(key) + 1, Number(a[1]), Number(a[2]) - 1);
|
|
68
|
+
}
|
|
56
69
|
}
|
|
57
|
-
|
|
70
|
+
}
|
|
71
|
+
// The last day of the period `date` falls in.
|
|
72
|
+
export function currentPeriodEnd(period, date, anniversary) {
|
|
73
|
+
const key = periodKey(period, date, anniversary);
|
|
74
|
+
return key === null ? null : periodEnd(period, key, anniversary);
|
|
58
75
|
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -2,13 +2,20 @@ import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchant
|
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
|
+
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
5
6
|
import { daysBetween } from "./periods.js";
|
|
6
7
|
import { ruleWhat } from "./rule-text.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
9
|
import { EPSILON, isClose } from "./compare.js";
|
|
9
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
10
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
10
11
|
import { cardValuation } from "./valuation.js";
|
|
11
12
|
export const STALE_AFTER_DAYS = 180;
|
|
13
|
+
// The engine only tells the U.S. from everywhere else.
|
|
14
|
+
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
15
|
+
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
16
|
+
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
17
|
+
// leaves unassigned, and nothing prints it.
|
|
18
|
+
export const ABROAD = 'ZZ';
|
|
12
19
|
// The warning when no card works; the CLI shows it as the answer instead.
|
|
13
20
|
export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
|
|
14
21
|
const NEAR_CAP_SHARE = 0.2;
|
|
@@ -66,14 +73,23 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
66
73
|
}
|
|
67
74
|
const valuation = cardValuation(catalog, wallet, card);
|
|
68
75
|
const evaluation = evaluateCard(card, held, ctx);
|
|
69
|
-
const
|
|
76
|
+
const milestones = milestonesReached(card, held, purchase.date, purchase.amount);
|
|
77
|
+
const bonusPoints = milestonePoints(milestones);
|
|
78
|
+
const perDollar = evaluation.best.rate +
|
|
79
|
+
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
80
|
+
// An unknown fee isn't guessed: it's warned about instead.
|
|
81
|
+
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
70
82
|
entries.push({
|
|
71
83
|
card,
|
|
72
84
|
held,
|
|
73
85
|
valuation,
|
|
74
86
|
evaluation,
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
milestones,
|
|
88
|
+
perDollar,
|
|
89
|
+
fee,
|
|
90
|
+
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
|
|
91
|
+
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
92
|
+
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
|
|
77
93
|
});
|
|
78
94
|
}
|
|
79
95
|
entries.sort(compareEntries);
|
|
@@ -96,6 +112,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
96
112
|
const over = amount - cap.remaining;
|
|
97
113
|
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
98
114
|
}
|
|
115
|
+
for (const hit of entry.milestones)
|
|
116
|
+
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
99
117
|
if (valuation.pooledFrom !== null) {
|
|
100
118
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
101
119
|
const heldPartners = wallet.cards
|
|
@@ -106,7 +124,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
106
124
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
107
125
|
}
|
|
108
126
|
if (currency !== 'cash') {
|
|
109
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.
|
|
127
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
|
|
110
128
|
}
|
|
111
129
|
return lines;
|
|
112
130
|
}
|
|
@@ -114,6 +132,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
114
132
|
const { card, valuation, evaluation } = entry;
|
|
115
133
|
const { best, nominal } = evaluation;
|
|
116
134
|
const warnings = [];
|
|
135
|
+
if (entry.fee > 0)
|
|
136
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
|
|
137
|
+
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
138
|
+
warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
|
|
117
139
|
const capLabel = (e) => e.cap
|
|
118
140
|
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
119
141
|
: '';
|
|
@@ -164,10 +186,19 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
164
186
|
rate: evaluation.best.rate,
|
|
165
187
|
currency: valuation.currency,
|
|
166
188
|
centsPerUnit: valuation.centsPerUnit,
|
|
167
|
-
effectiveReturnPct: entry.
|
|
189
|
+
effectiveReturnPct: entry.rateReturnPct,
|
|
190
|
+
foreignFee: entry.fee > 0 ? entry.fee : null,
|
|
168
191
|
earned: purchase.amount === null
|
|
169
192
|
? null
|
|
170
|
-
: Math.round(purchase.amount * entry.
|
|
193
|
+
: Math.round(purchase.amount * entry.rateReturnPct) / 100,
|
|
194
|
+
milestoneBonus: entry.milestones.length === 0
|
|
195
|
+
? null
|
|
196
|
+
: {
|
|
197
|
+
points: milestonePoints(entry.milestones),
|
|
198
|
+
dollars: roundCents((milestonePoints(entry.milestones) *
|
|
199
|
+
entry.valuation.centsPerUnit) /
|
|
200
|
+
100),
|
|
201
|
+
},
|
|
171
202
|
capped: evaluation.best.cap !== null,
|
|
172
203
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
173
204
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -194,6 +225,8 @@ function tieNote(entries) {
|
|
|
194
225
|
// Finds the closest point inside a currency's value range where the winner
|
|
195
226
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
196
227
|
// cents-per-point value, so each pair of cards flips at most once.
|
|
228
|
+
// "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
|
|
229
|
+
const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
|
|
197
230
|
function sensitivity(catalog, entries) {
|
|
198
231
|
const [winner, ...rest] = entries;
|
|
199
232
|
if (!winner)
|
|
@@ -208,18 +241,19 @@ function sensitivity(catalog, entries) {
|
|
|
208
241
|
const o = other.valuation;
|
|
209
242
|
if (w.currency === o.currency)
|
|
210
243
|
continue;
|
|
211
|
-
const wRate = winner.
|
|
212
|
-
const oRate = other.
|
|
244
|
+
const wRate = winner.perDollar;
|
|
245
|
+
const oRate = other.perDollar;
|
|
246
|
+
// Net return is rate × value − fee, so each flip point adds the fee back.
|
|
213
247
|
if (w.range && wRate > 0) {
|
|
214
|
-
const threshold = other.effectiveReturnPct / wRate;
|
|
248
|
+
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
215
249
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
216
|
-
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)}
|
|
250
|
+
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
217
251
|
}
|
|
218
252
|
}
|
|
219
253
|
if (o.range && oRate > 0) {
|
|
220
|
-
const threshold = winner.effectiveReturnPct / oRate;
|
|
254
|
+
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
221
255
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
222
|
-
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)}
|
|
256
|
+
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
223
257
|
}
|
|
224
258
|
}
|
|
225
259
|
}
|
|
@@ -234,7 +268,19 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
234
268
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
235
269
|
}
|
|
236
270
|
else if (merchant.category === 'other') {
|
|
237
|
-
|
|
271
|
+
// A card whose rule names this store by name (Audible on Amex's
|
|
272
|
+
// streaming list) earns its bonus there anyway.
|
|
273
|
+
const naming = entries.flatMap((e) => {
|
|
274
|
+
const rule = e.evaluation.best.rule;
|
|
275
|
+
return rule &&
|
|
276
|
+
merchant.merchantId !== null &&
|
|
277
|
+
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
278
|
+
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
279
|
+
: [];
|
|
280
|
+
});
|
|
281
|
+
assumptions.push(naming.length === 0
|
|
282
|
+
? `Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`
|
|
283
|
+
: `Assumes ${merchant.resolvedName} falls outside the usual bonus categories, except on cards that name it: ${listed(naming)}.`);
|
|
238
284
|
}
|
|
239
285
|
else {
|
|
240
286
|
assumptions.push(codesAs(catalog, merchant));
|
|
@@ -251,6 +297,11 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
251
297
|
const values = pointValuesText(catalog, entries.map((e) => e.valuation));
|
|
252
298
|
if (values !== null)
|
|
253
299
|
assumptions.push(values);
|
|
300
|
+
if (isForeign(purchase))
|
|
301
|
+
assumptions.push(FOREIGN_ASSUMPTION);
|
|
302
|
+
for (const e of entries)
|
|
303
|
+
for (const hit of e.milestones)
|
|
304
|
+
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.`);
|
|
254
305
|
return assumptions;
|
|
255
306
|
}
|
|
256
307
|
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
@@ -259,6 +310,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
259
310
|
const winner = entries[0];
|
|
260
311
|
const warnings = [];
|
|
261
312
|
const notes = [];
|
|
313
|
+
const charging = entries.filter((e) => e.fee > 0);
|
|
314
|
+
if (charging.length > 0 && entries.length > 1)
|
|
315
|
+
notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
|
|
262
316
|
if (merchant.source === 'none') {
|
|
263
317
|
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
|
|
264
318
|
}
|
package/dist/engine/record.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { milestoneProgress } from "./milestones.js";
|
|
4
5
|
import { roundCents } from "./text.js";
|
|
5
6
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
6
7
|
// when that cap is already used up, so usage stays accurate across a reset.
|
|
@@ -75,11 +76,48 @@ export function applyBonusBooking(wallet, b) {
|
|
|
75
76
|
: c),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
79
|
+
// Every purchase on a card counts toward each of its spend milestones. None
|
|
80
|
+
// for a cardmember-year milestone on a card with no anniversary.
|
|
81
|
+
export function milestoneBookings(catalog, wallet, purchase) {
|
|
82
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
83
|
+
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
84
|
+
if (!held || !card)
|
|
85
|
+
return [];
|
|
86
|
+
return milestoneProgress(card, held, purchase.date).flatMap((p) => p.period === null
|
|
87
|
+
? []
|
|
88
|
+
: [
|
|
89
|
+
{
|
|
90
|
+
cardId: card.id,
|
|
91
|
+
milestoneId: p.milestone.id,
|
|
92
|
+
period: p.period,
|
|
93
|
+
spend: p.milestone.spend,
|
|
94
|
+
before: p.spent,
|
|
95
|
+
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
export function applyMilestoneBooking(wallet, b) {
|
|
100
|
+
return {
|
|
101
|
+
...wallet,
|
|
102
|
+
cards: wallet.cards.map((c) => c.id === b.cardId
|
|
103
|
+
? {
|
|
104
|
+
...c,
|
|
105
|
+
milestone_usage: {
|
|
106
|
+
...c.milestone_usage,
|
|
107
|
+
[b.milestoneId]: { period: b.period, spent: b.after },
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: c),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
78
113
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
79
114
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
80
115
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
116
|
+
const milestones = milestoneBookings(catalog, wallet, purchase);
|
|
81
117
|
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
82
118
|
if (bonus)
|
|
83
119
|
next = applyBonusBooking(next, bonus);
|
|
84
|
-
|
|
120
|
+
for (const m of milestones)
|
|
121
|
+
next = applyMilestoneBooking(next, m);
|
|
122
|
+
return { wallet: next, cap, bonus, milestones };
|
|
85
123
|
}
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -26,14 +26,26 @@ export function optionName(catalog, card, choiceId, option) {
|
|
|
26
26
|
// What a rule covers, as a list of phrases.
|
|
27
27
|
export function ruleParts(catalog, rule) {
|
|
28
28
|
if (rule.match.any)
|
|
29
|
-
return [
|
|
29
|
+
return [
|
|
30
|
+
rule.geography === 'foreign_only'
|
|
31
|
+
? 'purchases outside the U.S.'
|
|
32
|
+
: 'everything else',
|
|
33
|
+
];
|
|
30
34
|
if (rule.match.choice !== undefined)
|
|
31
35
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
32
36
|
const merchants = rule.match.merchants ?? [];
|
|
33
|
-
// A long list of one kind of store reads as "select streaming
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
+
// A long list of mostly one kind of store reads as "select streaming
|
|
38
|
+
// services": Amex's streaming list also names Audible, which isn't one.
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const m of merchants) {
|
|
41
|
+
const category = findMerchant(catalog, m)?.category;
|
|
42
|
+
if (category)
|
|
43
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
|
|
46
|
+
const merchantParts = merchants.length > 4 &&
|
|
47
|
+
kind !== undefined &&
|
|
48
|
+
count >= merchants.length * 0.75
|
|
37
49
|
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
38
50
|
: merchants.map((m) => merchantName(catalog, m));
|
|
39
51
|
const parts = [
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
|
+
import { milestoneWhat } from "./milestones.js";
|
|
3
4
|
import { daysBetween } from "./periods.js";
|
|
4
5
|
import { rotationGap } from "./rotation.js";
|
|
5
6
|
import { idList, listed } from "./text.js";
|
|
@@ -39,7 +40,8 @@ function matcher(catalog, card) {
|
|
|
39
40
|
}
|
|
40
41
|
export function needsAnniversary(card) {
|
|
41
42
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
42
|
-
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year')
|
|
43
|
+
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year') ||
|
|
44
|
+
card.milestones.some((m) => m.period === 'cardmember_year'));
|
|
43
45
|
}
|
|
44
46
|
export function activationRules(card, today) {
|
|
45
47
|
return card.rules
|
|
@@ -74,6 +76,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
74
76
|
network: card.network,
|
|
75
77
|
currency: card.currency,
|
|
76
78
|
annualFee: card.annual_fee ?? null,
|
|
79
|
+
foreignTransactionFee: card.foreign_transaction_fee,
|
|
77
80
|
business: card.business,
|
|
78
81
|
onlyAt: card.only_at ?? null,
|
|
79
82
|
held: wallet?.cards.some((c) => c.id === card.id) ?? false,
|
|
@@ -94,6 +97,14 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
94
97
|
},
|
|
95
98
|
activation: activationRules(card, today),
|
|
96
99
|
needsAnniversary: needsAnniversary(card),
|
|
100
|
+
milestones: card.milestones.map((m) => ({
|
|
101
|
+
id: m.id,
|
|
102
|
+
spend: m.spend,
|
|
103
|
+
period: m.period,
|
|
104
|
+
points: m.points ?? null,
|
|
105
|
+
rewards: m.rewards ?? [],
|
|
106
|
+
text: milestoneWhat(m).replace(/^the /, ''),
|
|
107
|
+
})),
|
|
97
108
|
};
|
|
98
109
|
}
|
|
99
110
|
// Every word of the query has to match a word of the card's id, name or
|
|
@@ -162,7 +173,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
162
173
|
if (needsAnniversary(card) && held.anniversary === undefined) {
|
|
163
174
|
notes.push({
|
|
164
175
|
kind: 'anniversary',
|
|
165
|
-
text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
|
|
176
|
+
text: `${name} has a ${card.milestones.some((m) => m.period === 'cardmember_year') ? 'spend bonus' : 'cap'} that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
|
|
166
177
|
});
|
|
167
178
|
}
|
|
168
179
|
const gap = rotationGap(card, today);
|
package/dist/engine/simulate.js
CHANGED
|
@@ -4,8 +4,9 @@ import { idealHolding, matchesFilters, } from "./explore.js";
|
|
|
4
4
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
|
+
import { milestoneWhat } from "./milestones.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
|
-
import { money, roundCents } from "./text.js";
|
|
9
|
+
import { money, roundCents, wholeDollars } from "./text.js";
|
|
9
10
|
import { cardValuation } from "./valuation.js";
|
|
10
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
11
12
|
const MONTHS = 12;
|
|
@@ -122,7 +123,27 @@ held = []) {
|
|
|
122
123
|
cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
|
|
123
124
|
};
|
|
124
125
|
});
|
|
125
|
-
|
|
126
|
+
// A year of these amounts is taken as every year's: a calendar-year or
|
|
127
|
+
// cardmember-year total is the same twelve months' spending.
|
|
128
|
+
const milestones = [];
|
|
129
|
+
const milestoneNotes = [];
|
|
130
|
+
for (const { card, cents } of cards) {
|
|
131
|
+
const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
|
|
132
|
+
for (const m of card.milestones) {
|
|
133
|
+
if (spent + EPSILON < m.spend)
|
|
134
|
+
continue;
|
|
135
|
+
const value = roundCents(((m.points ?? 0) * cents) / 100);
|
|
136
|
+
milestones.push({
|
|
137
|
+
cardId: card.id,
|
|
138
|
+
milestoneId: m.id,
|
|
139
|
+
points: m.points ?? null,
|
|
140
|
+
value,
|
|
141
|
+
});
|
|
142
|
+
milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
|
|
146
|
+
milestones.reduce((sum, m) => sum + m.value, 0));
|
|
126
147
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
127
148
|
const paid = paidMemberships(catalog, {
|
|
128
149
|
memberships: wallet.memberships,
|
|
@@ -131,7 +152,7 @@ held = []) {
|
|
|
131
152
|
});
|
|
132
153
|
const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
|
|
133
154
|
const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
|
|
134
|
-
const notes = [];
|
|
155
|
+
const notes = [...milestoneNotes];
|
|
135
156
|
for (const m of memberships)
|
|
136
157
|
notes.push(membershipNote(catalog, m));
|
|
137
158
|
const dates = monthDates(start);
|
|
@@ -160,6 +181,7 @@ held = []) {
|
|
|
160
181
|
membershipFees,
|
|
161
182
|
net: roundCents(rewards - annualFees - membershipFees),
|
|
162
183
|
lines: lineResults,
|
|
184
|
+
milestones,
|
|
163
185
|
notes,
|
|
164
186
|
};
|
|
165
187
|
}
|
package/dist/engine/text.js
CHANGED
|
@@ -32,6 +32,12 @@ export const wholeDollars = (n) => money(Math.round(n));
|
|
|
32
32
|
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
|
|
33
33
|
// loss reads −$618.
|
|
34
34
|
export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
35
|
+
// "No foreign transaction fee", "3% foreign transaction fee"
|
|
36
|
+
export const foreignFeeText = (fee) => fee === null
|
|
37
|
+
? 'Foreign transaction fee not on file'
|
|
38
|
+
: fee === 0
|
|
39
|
+
? 'No foreign transaction fee'
|
|
40
|
+
: `${pct(fee)} foreign transaction fee`;
|
|
35
41
|
export function feeText(fees) {
|
|
36
42
|
const { annualFee, firstYearFee } = fees;
|
|
37
43
|
if (annualFee === null)
|
|
@@ -122,6 +128,11 @@ export const CAP_ADJECTIVES = {
|
|
|
122
128
|
calendar_year: 'calendar-year',
|
|
123
129
|
cardmember_year: 'cardmember-year',
|
|
124
130
|
};
|
|
131
|
+
// "the $15,000 calendar-year spend", for a spend milestone.
|
|
132
|
+
export const MILESTONE_PERIODS = {
|
|
133
|
+
calendar_year: 'calendar-year',
|
|
134
|
+
cardmember_year: 'cardmember-year',
|
|
135
|
+
};
|
|
125
136
|
export const PICK_PERIODS = {
|
|
126
137
|
month: 'each month',
|
|
127
138
|
quarter: 'each quarter',
|
|
@@ -116,6 +116,28 @@ export function cardFields(card, held, input, today) {
|
|
|
116
116
|
}
|
|
117
117
|
fields.cap_usage = usage;
|
|
118
118
|
}
|
|
119
|
+
if (input.milestoneSpent !== undefined) {
|
|
120
|
+
const usage = { ...held?.milestone_usage };
|
|
121
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
122
|
+
for (const [id, spent] of Object.entries(input.milestoneSpent)) {
|
|
123
|
+
const milestone = card.milestones.find((m) => m.id === id);
|
|
124
|
+
if (!milestone) {
|
|
125
|
+
problems.push(card.milestones.length > 0
|
|
126
|
+
? `${card.id} has no spend milestone '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
|
|
127
|
+
: `${card.id} has no spend milestones.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!(spent >= 0))
|
|
131
|
+
problems.push('Spending toward a milestone must be $0 or more.');
|
|
132
|
+
const period = periodKey(milestone.period, today, anniversary);
|
|
133
|
+
if (period === null) {
|
|
134
|
+
problems.push(`The '${id}' milestone resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
usage[id] = { period, spent };
|
|
138
|
+
}
|
|
139
|
+
fields.milestone_usage = usage;
|
|
140
|
+
}
|
|
119
141
|
if (input.bonus === null)
|
|
120
142
|
fields.bonus = null;
|
|
121
143
|
else if (input.bonus !== undefined) {
|
|
@@ -173,6 +195,9 @@ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
|
173
195
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
174
196
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
175
197
|
...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
|
|
198
|
+
...(f.milestone_usage === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { milestone_usage: f.milestone_usage }),
|
|
176
201
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
177
202
|
});
|
|
178
203
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -266,3 +291,45 @@ export function withValuations(catalog, wallet, changes) {
|
|
|
266
291
|
}
|
|
267
292
|
// Higher than any program's points are worth, even transferred.
|
|
268
293
|
const MAX_CENTS = 5;
|
|
294
|
+
// Point values keyed the way people say them ("ur", "Ultimate Rewards",
|
|
295
|
+
// "Chase points"), turned into currency ids. A key that could mean more
|
|
296
|
+
// than one currency is refused rather than guessed.
|
|
297
|
+
export function currencyChanges(catalog, changes) {
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [key, cents] of Object.entries(changes)) {
|
|
300
|
+
const matches = currenciesFor(catalog, key);
|
|
301
|
+
const only = matches[0];
|
|
302
|
+
if (matches.length > 1)
|
|
303
|
+
return failed(`'${key}' could be ${matches.map((c) => `${c.id} (${c.name})`).join(' or ')}; give the currency id.`);
|
|
304
|
+
if (only === undefined)
|
|
305
|
+
return failed(`Unknown currency '${key}'. Currencies: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
|
|
306
|
+
if (only.id in out)
|
|
307
|
+
return failed(`More than one value was given for ${only.name}.`);
|
|
308
|
+
out[only.id] = cents;
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, changes: out };
|
|
311
|
+
}
|
|
312
|
+
const IGNORED_WORDS = new Set(['points', 'point', 'pts', 'miles', 'mile']);
|
|
313
|
+
const words = (text) => text
|
|
314
|
+
.toLowerCase()
|
|
315
|
+
.split(/[^a-z0-9]+/)
|
|
316
|
+
.filter((w) => w !== '' && !IGNORED_WORDS.has(w));
|
|
317
|
+
const sameWords = (a, b) => a.length === b.length && a.every((w, i) => w === b[i]);
|
|
318
|
+
function currenciesFor(catalog, key) {
|
|
319
|
+
const exact = catalog.currencies.find((c) => c.id === key);
|
|
320
|
+
if (exact)
|
|
321
|
+
return [exact];
|
|
322
|
+
const k = words(key);
|
|
323
|
+
if (k.length === 0)
|
|
324
|
+
return [];
|
|
325
|
+
const byId = catalog.currencies.filter((c) => sameWords(words(c.id), k));
|
|
326
|
+
if (byId.length > 0)
|
|
327
|
+
return byId;
|
|
328
|
+
const byName = catalog.currencies.filter((c) => sameWords(words(c.name), k));
|
|
329
|
+
if (byName.length > 0)
|
|
330
|
+
return byName;
|
|
331
|
+
return catalog.currencies.filter((c) => {
|
|
332
|
+
const name = words(c.name);
|
|
333
|
+
return name.some((_, i) => sameWords(name.slice(i, i + k.length), k));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
3
|
import { membershipName } from "./memberships.js";
|
|
4
|
+
import { milestoneSummaries } from "./milestones.js";
|
|
4
5
|
import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
|
|
5
|
-
import { daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
6
|
+
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
6
7
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
9
|
import { activationRules } from "./setup.js";
|
|
@@ -77,7 +78,9 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
77
78
|
const after = `${limit}${activate}`;
|
|
78
79
|
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
79
80
|
}),
|
|
80
|
-
...(shown.some((r) => r.match.any &&
|
|
81
|
+
...(shown.some((r) => r.match.any &&
|
|
82
|
+
r.geography !== 'foreign_only' &&
|
|
83
|
+
r.rate > card.base_rate)
|
|
81
84
|
? []
|
|
82
85
|
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
83
86
|
];
|
|
@@ -114,6 +117,13 @@ function toActivate(catalog, card, held, today) {
|
|
|
114
117
|
return `${a.what}${when === '' ? '' : ` (${when})`}`;
|
|
115
118
|
});
|
|
116
119
|
}
|
|
120
|
+
// Picks that carry over have no end date.
|
|
121
|
+
function picksUntil(card, key) {
|
|
122
|
+
if (card.choices.every((c) => c.period === 'until_changed'))
|
|
123
|
+
return null;
|
|
124
|
+
const period = choicePeriod(card);
|
|
125
|
+
return period === null ? null : periodEnd(period, key, null);
|
|
126
|
+
}
|
|
117
127
|
function capSummaries(catalog, card, held, today) {
|
|
118
128
|
const caps = new Map();
|
|
119
129
|
for (const shared of card.caps)
|
|
@@ -143,6 +153,9 @@ function capSummaries(catalog, card, held, today) {
|
|
|
143
153
|
amount: cap.amount,
|
|
144
154
|
period: cap.period,
|
|
145
155
|
currentPeriod,
|
|
156
|
+
until: currentPeriod === null
|
|
157
|
+
? null
|
|
158
|
+
: periodEnd(cap.period, currentPeriod, held.anniversary ?? null),
|
|
146
159
|
spent,
|
|
147
160
|
remaining: currentPeriod === null ? null : Math.max(0, cap.amount - spent),
|
|
148
161
|
};
|
|
@@ -180,14 +193,13 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
180
193
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
181
194
|
rotationGap: rotationGap(card, today),
|
|
182
195
|
caps: capSummaries(catalog, card, held, today),
|
|
196
|
+
milestones: milestoneSummaries(card, held, today),
|
|
183
197
|
chosen: held.chosen
|
|
184
198
|
? {
|
|
185
199
|
period: held.chosen.period,
|
|
186
200
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
187
201
|
// Picks that carry over have no end date.
|
|
188
|
-
until: card.
|
|
189
|
-
? null
|
|
190
|
-
: periodEnd(held.chosen.period),
|
|
202
|
+
until: picksUntil(card, held.chosen.period),
|
|
191
203
|
picks: held.chosen.picks,
|
|
192
204
|
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
193
205
|
choiceName(choice),
|
package/dist/format/cards.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { money, NETWORK_NAMES } from "../engine/text.js";
|
|
1
|
+
import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
2
|
export function formatCardInfo(card) {
|
|
3
3
|
const fee = card.annualFee === null
|
|
4
4
|
? ''
|
|
@@ -19,7 +19,9 @@ export function formatCardInfo(card) {
|
|
|
19
19
|
? [`activate with the issuer: ${card.activation.join(', ')}`]
|
|
20
20
|
: []),
|
|
21
21
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
|
+
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
22
23
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
|
+
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
23
25
|
];
|
|
24
26
|
return [
|
|
25
27
|
`${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
|
|
@@ -30,7 +30,10 @@ function cardLines(cards, style, bonusPick) {
|
|
|
30
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
31
31
|
const lines = [];
|
|
32
32
|
cards.forEach((c) => {
|
|
33
|
-
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}
|
|
33
|
+
const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
|
|
34
|
+
(c.milestoneBonus === null
|
|
35
|
+
? ''
|
|
36
|
+
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
|
|
34
37
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
38
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
36
39
|
for (const r of c.reasoning)
|
|
@@ -53,9 +56,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
53
56
|
const noCard = noCardText(rec.merchant.resolvedName);
|
|
54
57
|
if (rec.decision && top) {
|
|
55
58
|
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
59
|
+
const milestone = top.milestoneBonus === null
|
|
60
|
+
? ''
|
|
61
|
+
: `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
|
|
56
62
|
const confidence = rec.decision.confidence;
|
|
57
63
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
58
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
|
|
64
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
|
|
59
65
|
if (rec.bonus && rec.notes[0])
|
|
60
66
|
lines.push(rec.notes[0]);
|
|
61
67
|
}
|