earnrate 0.2.12 → 0.2.14
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 +20 -7
- package/data/CHANGELOG.md +197 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +28 -2
- package/data/cards/amex-blue-cash-preferred.yaml +19 -0
- package/data/cards/amex-business-gold.yaml +38 -3
- package/data/cards/amex-business-platinum.yaml +72 -3
- package/data/cards/amex-delta-skymiles-gold.yaml +34 -9
- package/data/cards/amex-delta-skymiles-platinum.yaml +40 -9
- package/data/cards/amex-delta-skymiles-reserve.yaml +42 -10
- package/data/cards/amex-gold.yaml +52 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
- package/data/cards/amex-hilton-honors-business.yaml +14 -1
- package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
- package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
- package/data/cards/amex-platinum.yaml +110 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bilt-obsidian.yaml +12 -2
- package/data/cards/bilt-palladium.yaml +12 -2
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-venture-x-business.yaml +28 -1
- package/data/cards/capital-one-venture-x.yaml +30 -2
- package/data/cards/chase-aeroplan.yaml +14 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
- package/data/cards/chase-ink-business-cash.yaml +13 -0
- package/data/cards/chase-ink-business-unlimited.yaml +13 -2
- package/data/cards/chase-sapphire-preferred.yaml +11 -0
- package/data/cards/chase-sapphire-reserve.yaml +44 -4
- package/data/cards/chase-united-business.yaml +32 -0
- package/data/cards/chase-united-club-business.yaml +40 -0
- package/data/cards/chase-united-club.yaml +40 -0
- package/data/cards/chase-united-explorer.yaml +32 -0
- package/data/cards/chase-united-quest.yaml +40 -0
- package/data/cards/citi-aadvantage-executive.yaml +26 -1
- package/data/cards/citi-strata-elite.yaml +20 -1
- package/data/cards/discover-it-cash-back.yaml +8 -5
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +13 -2
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/usbank-altitude-connect.yaml +8 -2
- package/data/cards/usbank-altitude-go.yaml +8 -2
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/currencies.yaml +14 -0
- package/data/issuers.yaml +22 -0
- package/data/memberships.yaml +35 -0
- package/data/merchants/overrides.yaml +448 -0
- package/data/merchants/seed.yaml +1085 -66
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +31 -4
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +22 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +25 -6
- package/dist/engine/cheat-sheet.js +20 -7
- package/dist/engine/checklist.js +35 -11
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +38 -11
- package/dist/engine/explore.js +72 -9
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +101 -38
- package/dist/engine/record.js +42 -1
- package/dist/engine/rotation.js +4 -0
- package/dist/engine/rule-text.js +35 -7
- package/dist/engine/setup.js +17 -6
- package/dist/engine/simulate.js +13 -4
- package/dist/engine/text.js +89 -1
- package/dist/engine/wallet-change.js +37 -1
- package/dist/engine/wallet-summary.js +32 -4
- package/dist/format/card-text.js +4 -3
- package/dist/format/cards.js +6 -2
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet-yaml.js +11 -15
- package/dist/format/wallet.js +15 -7
- package/dist/import/analyze.js +7 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +71 -1
- package/dist/mcp/tools/answers.js +11 -5
- package/dist/mcp/tools/wallet.js +15 -7
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +37 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/merchant.js +6 -0
- package/dist/schema/wallet.js +8 -0
- package/package.json +1 -1
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 { creditAt, creditDollars } from "./credits.js";
|
|
4
5
|
import { milestoneProgress } from "./milestones.js";
|
|
5
6
|
import { roundCents } from "./text.js";
|
|
6
7
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
@@ -95,6 +96,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
|
|
|
95
96
|
spend: p.milestone.spend,
|
|
96
97
|
before: p.spent,
|
|
97
98
|
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
|
99
|
+
started: !p.tracked,
|
|
98
100
|
},
|
|
99
101
|
]);
|
|
100
102
|
}
|
|
@@ -112,14 +114,53 @@ export function applyMilestoneBooking(wallet, b) {
|
|
|
112
114
|
: c),
|
|
113
115
|
};
|
|
114
116
|
}
|
|
117
|
+
// The part of an enrolled merchant credit this purchase uses. None when the
|
|
118
|
+
// card has no credit here, it isn't enrolled, or it's used up.
|
|
119
|
+
export function creditBooking(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
120
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
121
|
+
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
122
|
+
if (!held || !card)
|
|
123
|
+
return null;
|
|
124
|
+
const state = creditAt(catalog, card, held, merchant, purchase.date);
|
|
125
|
+
const back = state ? creditDollars(state, purchase.amount) : null;
|
|
126
|
+
if (!state || state.key === null || back === null || back <= 0)
|
|
127
|
+
return null;
|
|
128
|
+
return {
|
|
129
|
+
cardId: card.id,
|
|
130
|
+
creditId: state.credit.id,
|
|
131
|
+
name: state.name,
|
|
132
|
+
period: state.key,
|
|
133
|
+
periodKind: state.period,
|
|
134
|
+
amount: state.amount,
|
|
135
|
+
before: state.used,
|
|
136
|
+
after: roundCents(state.used + back),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function applyCreditBooking(wallet, b) {
|
|
140
|
+
return {
|
|
141
|
+
...wallet,
|
|
142
|
+
cards: wallet.cards.map((c) => c.id === b.cardId
|
|
143
|
+
? {
|
|
144
|
+
...c,
|
|
145
|
+
credit_usage: {
|
|
146
|
+
...c.credit_usage,
|
|
147
|
+
[b.creditId]: { period: b.period, used: b.after },
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
: c),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
115
153
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
116
154
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
117
155
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
118
156
|
const milestones = milestoneBookings(catalog, wallet, purchase);
|
|
157
|
+
const credit = creditBooking(catalog, wallet, purchase, merchant);
|
|
119
158
|
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
120
159
|
if (bonus)
|
|
121
160
|
next = applyBonusBooking(next, bonus);
|
|
122
161
|
for (const m of milestones)
|
|
123
162
|
next = applyMilestoneBooking(next, m);
|
|
124
|
-
|
|
163
|
+
if (credit)
|
|
164
|
+
next = applyCreditBooking(next, credit);
|
|
165
|
+
return { wallet: next, cap, bonus, milestones, credit };
|
|
125
166
|
}
|
package/dist/engine/rotation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { periodKey } from "./periods.js";
|
|
2
|
+
import { possessive } from "./text.js";
|
|
2
3
|
// The period of the card's rotating categories that `date` falls in, when no
|
|
3
4
|
// rule in the data covers it yet; null when it's covered or the card doesn't
|
|
4
5
|
// rotate.
|
|
@@ -14,3 +15,6 @@ export function rotationGap(card, date) {
|
|
|
14
15
|
}
|
|
15
16
|
return null;
|
|
16
17
|
}
|
|
18
|
+
// What answers say about a gap: "Freedom Flex's rotating categories for
|
|
19
|
+
// 2027-Q2 aren't in earnrate's data yet. ..."
|
|
20
|
+
export const rotationGapText = (name, gap) => `${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, choiceName, earnText, introPeriod, listed, num, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, 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.
|
|
6
|
-
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
6
|
+
const BOOKING = /^(Activity|Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
7
|
+
const plural = (kind) => kind === 'Activity' ? 'activities' : `${kind.toLowerCase()}s`;
|
|
7
8
|
function merchantName(catalog, id) {
|
|
8
9
|
const merchant = findMerchant(catalog, id);
|
|
9
10
|
const name = merchant?.name ?? words(id);
|
|
10
|
-
return merchant?.kind === 'booking'
|
|
11
|
-
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
12
|
-
: name;
|
|
11
|
+
return merchant?.kind === 'booking' ? name.replace(BOOKING, plural) : name;
|
|
13
12
|
}
|
|
14
13
|
export function optionName(catalog, card, choiceId, option) {
|
|
15
14
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
@@ -29,7 +28,9 @@ export function ruleParts(catalog, rule) {
|
|
|
29
28
|
return [
|
|
30
29
|
rule.geography === 'foreign_only'
|
|
31
30
|
? 'purchases outside the U.S.'
|
|
32
|
-
:
|
|
31
|
+
: rule.requires?.payment !== undefined
|
|
32
|
+
? 'purchases'
|
|
33
|
+
: 'everything else',
|
|
33
34
|
];
|
|
34
35
|
if (rule.match.choice !== undefined)
|
|
35
36
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
@@ -55,7 +56,9 @@ export function ruleParts(catalog, rule) {
|
|
|
55
56
|
return parts.length > 0 ? parts : [words(rule.id)];
|
|
56
57
|
}
|
|
57
58
|
export function ruleWhat(catalog, rule) {
|
|
58
|
-
|
|
59
|
+
const payment = rule.requires?.payment;
|
|
60
|
+
const what = listed(ruleParts(catalog, rule));
|
|
61
|
+
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
59
62
|
}
|
|
60
63
|
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
61
64
|
// picks for the first year".
|
|
@@ -71,3 +74,28 @@ export function introWhat(catalog, card, intro) {
|
|
|
71
74
|
: `${num(intro.multiply)} times ${on}`;
|
|
72
75
|
return `${change} for the ${introPeriod(intro.days)}`;
|
|
73
76
|
}
|
|
77
|
+
// What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
|
|
78
|
+
// Bookings through a travel site the credit also names go without saying:
|
|
79
|
+
// "Capital One Travel", not each kind of booking through it.
|
|
80
|
+
export function creditName(catalog, credit) {
|
|
81
|
+
const ids = credit.match.merchants ?? [];
|
|
82
|
+
const portals = ids
|
|
83
|
+
.map((m) => findMerchant(catalog, m))
|
|
84
|
+
.filter((m) => m?.kind === 'portal')
|
|
85
|
+
.map((m) => ` through ${m?.name ?? ''}`);
|
|
86
|
+
const merchants = ids.filter((m) => {
|
|
87
|
+
const merchant = findMerchant(catalog, m);
|
|
88
|
+
return !(merchant?.kind === 'booking' &&
|
|
89
|
+
portals.some((p) => merchant.name.endsWith(p)));
|
|
90
|
+
});
|
|
91
|
+
return listed([
|
|
92
|
+
...merchants.map((m) => merchantName(catalog, m)),
|
|
93
|
+
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
// "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
|
|
97
|
+
// the category's or merchant's own preposition.
|
|
98
|
+
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
|
|
99
|
+
(credit.match.merchants ?? []).some((m) => findMerchant(catalog, m)?.preposition === 'on')
|
|
100
|
+
? 'on'
|
|
101
|
+
: 'at'} ${creditName(catalog, credit)}`;
|
package/dist/engine/setup.js
CHANGED
|
@@ -2,9 +2,9 @@ import { isActivated, pickedCategories } from "./evaluate.js";
|
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
3
|
import { milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
|
-
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import { introWhat } from "./rule-text.js";
|
|
7
|
-
import { idList, listed } from "./text.js";
|
|
5
|
+
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
|
+
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
+
import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -111,6 +111,17 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
111
111
|
days: i.days,
|
|
112
112
|
text: introWhat(catalog, card, i),
|
|
113
113
|
})),
|
|
114
|
+
credits: card.credits.map((c) => {
|
|
115
|
+
const name = creditName(catalog, c);
|
|
116
|
+
return {
|
|
117
|
+
id: c.id,
|
|
118
|
+
name,
|
|
119
|
+
amount: c.amount,
|
|
120
|
+
period: c.period,
|
|
121
|
+
enroll: c.enroll === true,
|
|
122
|
+
text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
114
125
|
};
|
|
115
126
|
}
|
|
116
127
|
// Every word of the query has to match a word of the card's id, name or
|
|
@@ -157,7 +168,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
157
168
|
notes.push({
|
|
158
169
|
kind: 'pick',
|
|
159
170
|
text: choice.default && picked.has(choice.id)
|
|
160
|
-
? `${name}
|
|
171
|
+
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
161
172
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
162
173
|
});
|
|
163
174
|
}
|
|
@@ -166,7 +177,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
166
177
|
if (missing.length > 0) {
|
|
167
178
|
notes.push({
|
|
168
179
|
kind: 'membership',
|
|
169
|
-
text: `Some of ${name}
|
|
180
|
+
text: `Some of ${possessive(name)} rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
170
181
|
});
|
|
171
182
|
}
|
|
172
183
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
@@ -186,7 +197,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
186
197
|
if (gap !== null) {
|
|
187
198
|
notes.push({
|
|
188
199
|
kind: 'rotation',
|
|
189
|
-
text:
|
|
200
|
+
text: rotationGapText(name, gap),
|
|
190
201
|
});
|
|
191
202
|
}
|
|
192
203
|
return notes;
|
package/dist/engine/simulate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { milestoneWhat } from "./milestones.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, roundCents, wholeDollars } from "./text.js";
|
|
9
|
+
import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
12
|
const MONTHS = 12;
|
|
@@ -155,19 +155,22 @@ held = []) {
|
|
|
155
155
|
const notes = [...milestoneNotes];
|
|
156
156
|
for (const m of memberships)
|
|
157
157
|
notes.push(membershipNote(catalog, m));
|
|
158
|
+
const paying = paymentNote(cards.map(({ card }) => ({ name: card.name, card })));
|
|
159
|
+
if (paying !== null)
|
|
160
|
+
notes.push(paying);
|
|
158
161
|
const dates = monthDates(start);
|
|
159
162
|
for (const { card } of cards) {
|
|
160
163
|
const gaps = [
|
|
161
164
|
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
162
165
|
];
|
|
163
166
|
if (gaps.length > 0)
|
|
164
|
-
notes.push(`${card.name}
|
|
167
|
+
notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
165
168
|
if (card.annual_fee === undefined)
|
|
166
|
-
notes.push(`${card.name}
|
|
169
|
+
notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
|
|
167
170
|
else if (!held.includes(card.id) &&
|
|
168
171
|
card.first_year_fee !== undefined &&
|
|
169
172
|
card.first_year_fee !== card.annual_fee)
|
|
170
|
-
notes.push(`${card.name}
|
|
173
|
+
notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
171
174
|
}
|
|
172
175
|
return {
|
|
173
176
|
cards: wallet.cards.map((h) => ({
|
|
@@ -240,6 +243,12 @@ function bestSimulation(catalog, base, card, lines, options) {
|
|
|
240
243
|
}
|
|
241
244
|
const costs = (s) => s.annualFees + s.membershipFees;
|
|
242
245
|
const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
|
|
246
|
+
// Each card on its own with its best picks, best net first.
|
|
247
|
+
export function simulateSingles(catalog, cards, lines, options) {
|
|
248
|
+
return cards
|
|
249
|
+
.map((card) => bestSimulation(catalog, [], card, lines, options))
|
|
250
|
+
.sort(byNet);
|
|
251
|
+
}
|
|
243
252
|
// Pairs that tie and share their first card differ only in an
|
|
244
253
|
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
245
254
|
function foldTies(pairs) {
|
package/dist/engine/text.js
CHANGED
|
@@ -101,8 +101,36 @@ export const NETWORK_NAMES = {
|
|
|
101
101
|
discover: 'Discover',
|
|
102
102
|
store: 'store card',
|
|
103
103
|
};
|
|
104
|
+
// How a purchase is paid, mid-sentence: "2% cash back with Apple Pay".
|
|
105
|
+
export const PAYMENT_PHRASES = {
|
|
106
|
+
card: 'with the card itself',
|
|
107
|
+
'apple-pay': 'with Apple Pay',
|
|
108
|
+
'google-pay': 'with Google Pay',
|
|
109
|
+
paypal: 'through PayPal checkout',
|
|
110
|
+
};
|
|
111
|
+
export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
|
|
112
|
+
export const paymentUnknownText = (name, payment, earn, standard) => `${capitalize(PAYMENT_PHRASES[payment])}, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Say how you're paying to count it.`;
|
|
113
|
+
// For answers that never ask how each purchase is paid (cheat sheets and
|
|
114
|
+
// simulations): which cards' rates that need a way of paying are left out.
|
|
115
|
+
export function paymentNote(cards) {
|
|
116
|
+
const payments = new Set();
|
|
117
|
+
const names = [];
|
|
118
|
+
for (const { name, card } of cards) {
|
|
119
|
+
const needs = card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]);
|
|
120
|
+
if (needs.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
names.push(name);
|
|
123
|
+
for (const p of needs)
|
|
124
|
+
payments.add(p);
|
|
125
|
+
}
|
|
126
|
+
if (names.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
return `Rates ${listed([...payments].map((p) => PAYMENT_PHRASES[p]), 'or')} (${listed(names)}) aren't counted: this ranks each card by what it earns however you pay.`;
|
|
129
|
+
}
|
|
104
130
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
105
131
|
// list's: those lists use commas only, with those names last.
|
|
132
|
+
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
133
|
+
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
106
134
|
export const listed = (parts, joiner = 'and') => {
|
|
107
135
|
const anded = parts.filter((p) => p.includes(' and '));
|
|
108
136
|
if (anded.length > 0)
|
|
@@ -161,4 +189,64 @@ export const introSince = (days) => days === 365 ? 'in the last year' : `in the
|
|
|
161
189
|
export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
|
|
162
190
|
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
163
191
|
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}
|
|
192
|
+
export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
193
|
+
// Merchant statement credits. Always dollars, never a percentage.
|
|
194
|
+
export const CREDIT_PERIODS = {
|
|
195
|
+
month: 'each month',
|
|
196
|
+
quarter: 'each quarter',
|
|
197
|
+
half_year: 'each half year',
|
|
198
|
+
calendar_year: 'each calendar year',
|
|
199
|
+
cardmember_year: 'each cardmember year',
|
|
200
|
+
};
|
|
201
|
+
export const CREDIT_THIS_PERIOD = {
|
|
202
|
+
month: 'this month',
|
|
203
|
+
quarter: 'this quarter',
|
|
204
|
+
half_year: 'this half year',
|
|
205
|
+
calendar_year: 'this year',
|
|
206
|
+
cardmember_year: 'this cardmember year',
|
|
207
|
+
};
|
|
208
|
+
export const ENROLL_FIRST = 'enroll first';
|
|
209
|
+
// The group of cards with a credit at the store, shown before the ranking.
|
|
210
|
+
export const CREDIT_GROUP = 'With a store credit';
|
|
211
|
+
// "$75 back each quarter"
|
|
212
|
+
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
213
|
+
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
214
|
+
export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
|
|
215
|
+
// "$35 left this quarter"
|
|
216
|
+
export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
|
|
217
|
+
// A line for an answer that uses the card's credit, or says why it doesn't.
|
|
218
|
+
export function creditAnswerText(c, dollars) {
|
|
219
|
+
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
220
|
+
if (!c.enrolled)
|
|
221
|
+
return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
222
|
+
if (c.left === null)
|
|
223
|
+
return `Has a ${credit} here; give the card's anniversary to track it.`;
|
|
224
|
+
if (c.left <= 0)
|
|
225
|
+
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
226
|
+
if (dollars === null)
|
|
227
|
+
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
|
|
228
|
+
return dollars < c.left
|
|
229
|
+
? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
|
|
230
|
+
: `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
|
|
231
|
+
}
|
|
232
|
+
// One short line beside a ranked card: "+ $35 back from its Lululemon
|
|
233
|
+
// credit", "Lululemon credit: $35 left this quarter".
|
|
234
|
+
export function creditShort(c, dollars) {
|
|
235
|
+
if (!c.enrolled)
|
|
236
|
+
return `${c.name} credit: ${creditBack(c)}, ${ENROLL_FIRST}`;
|
|
237
|
+
if (c.left === null)
|
|
238
|
+
return `${c.name} credit: ${creditBack(c)}`;
|
|
239
|
+
if (c.left <= 0)
|
|
240
|
+
return `${c.name} credit used up ${CREDIT_THIS_PERIOD[c.period]}`;
|
|
241
|
+
if (dollars === null || dollars <= 0)
|
|
242
|
+
return `${c.name} credit: ${creditLeft(c.left, c.period)}`;
|
|
243
|
+
return `+ ${money(dollars)} back from its ${c.name} credit`;
|
|
244
|
+
}
|
|
245
|
+
// For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
|
|
246
|
+
// 31, 2026: $35 left."
|
|
247
|
+
export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
|
|
248
|
+
export const enrollCreditText = (card, names) => names.length === 1
|
|
249
|
+
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
250
|
+
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
251
|
+
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
252
|
+
export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
|
|
@@ -4,7 +4,7 @@ import { addMonths } from "./bonus.js";
|
|
|
4
4
|
import { capByKey } from "./evaluate.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
7
|
-
import { idList } from "./text.js";
|
|
7
|
+
import { CREDIT_PERIODS, idList, money } from "./text.js";
|
|
8
8
|
const failed = (error) => ({ ok: false, error });
|
|
9
9
|
function findCatalogCard(catalog, id, today) {
|
|
10
10
|
const card = catalog.cards.find((c) => c.id === id);
|
|
@@ -149,6 +149,40 @@ export function cardFields(card, held, input, today) {
|
|
|
149
149
|
}
|
|
150
150
|
fields.milestone_usage = usage;
|
|
151
151
|
}
|
|
152
|
+
if (input.enrolled !== undefined) {
|
|
153
|
+
const needing = card.credits
|
|
154
|
+
.filter((c) => c.enroll === true)
|
|
155
|
+
.map((c) => c.id);
|
|
156
|
+
for (const id of input.enrolled) {
|
|
157
|
+
if (!needing.includes(id))
|
|
158
|
+
problems.push(needing.length > 0
|
|
159
|
+
? `'${id}' isn't a ${card.id} credit that needs enrolling. Those are: ${idList(needing)}.`
|
|
160
|
+
: `${card.id} has no credits that need enrolling.`);
|
|
161
|
+
}
|
|
162
|
+
fields.enrolled = [...new Set(input.enrolled)];
|
|
163
|
+
}
|
|
164
|
+
if (input.creditUsed !== undefined) {
|
|
165
|
+
const usage = { ...held?.credit_usage };
|
|
166
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
167
|
+
for (const [id, used] of Object.entries(input.creditUsed)) {
|
|
168
|
+
const credit = card.credits.find((c) => c.id === id);
|
|
169
|
+
if (!credit) {
|
|
170
|
+
problems.push(card.credits.length > 0
|
|
171
|
+
? `${card.id} has no credit '${id}'. Its credits are: ${idList(card.credits.map((c) => c.id))}.`
|
|
172
|
+
: `${card.id} has no merchant credits.`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!(used >= 0 && used <= credit.amount))
|
|
176
|
+
problems.push(`The '${id}' credit is ${money(credit.amount)} ${CREDIT_PERIODS[credit.period]}, so what's used must be $0 to ${money(credit.amount)}.`);
|
|
177
|
+
const period = periodKey(credit.period, today, anniversary);
|
|
178
|
+
if (period === null) {
|
|
179
|
+
problems.push(`The '${id}' credit resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
usage[id] = { period, used };
|
|
183
|
+
}
|
|
184
|
+
fields.credit_usage = usage;
|
|
185
|
+
}
|
|
152
186
|
if (input.bonus === null)
|
|
153
187
|
fields.bonus = null;
|
|
154
188
|
else if (input.bonus !== undefined) {
|
|
@@ -215,6 +249,8 @@ const withFields = ({ bonus, checkout_label, opened, ...held }, f) => ({
|
|
|
215
249
|
...(f.milestone_usage === undefined
|
|
216
250
|
? {}
|
|
217
251
|
: { milestone_usage: f.milestone_usage }),
|
|
252
|
+
...(f.enrolled === undefined ? {} : { enrolled: f.enrolled }),
|
|
253
|
+
...(f.credit_usage === undefined ? {} : { credit_usage: f.credit_usage }),
|
|
218
254
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
219
255
|
});
|
|
220
256
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -3,12 +3,13 @@ import { activationGroup, capUsed, isActivated, pickedCategories, } from "./eval
|
|
|
3
3
|
import { introUntil, introWindows, openedOn } from "./intro.js";
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
5
|
import { milestoneSummaries } from "./milestones.js";
|
|
6
|
-
import {
|
|
6
|
+
import { creditStates } from "./credits.js";
|
|
7
|
+
import { creditWhere, optionName, ruleParts } from "./rule-text.js";
|
|
7
8
|
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
8
9
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
9
10
|
import { rotationGap } from "./rotation.js";
|
|
10
11
|
import { activationRules } from "./setup.js";
|
|
11
|
-
import { CAP_PERIODS, categoryReadsOn, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
12
|
+
import { CAP_PERIODS, categoryReadsOn, creditBack, creditLeft, ENROLL_FIRST, choiceName, currencyLabel, earnText, listed, PAYMENT_PHRASES, shortDate, wholeDollars, words, } from "./text.js";
|
|
12
13
|
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
13
14
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
14
15
|
? []
|
|
@@ -62,10 +63,12 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
62
63
|
if (replaced)
|
|
63
64
|
continue;
|
|
64
65
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
66
|
+
const payment = r.requires?.payment;
|
|
65
67
|
const key = [
|
|
66
68
|
`${earnText(catalog, r.rate, currency)} on`,
|
|
67
69
|
capLimit(card, shown, r),
|
|
68
70
|
pending ? ', once activated' : '',
|
|
71
|
+
payment === undefined ? '' : ` ${PAYMENT_PHRASES[payment]}`,
|
|
69
72
|
].join('|');
|
|
70
73
|
const whats = lines.get(key) ?? [];
|
|
71
74
|
for (const part of ruleParts(catalog, r))
|
|
@@ -75,12 +78,13 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
75
78
|
}
|
|
76
79
|
return [
|
|
77
80
|
...[...lines].map(([key, whats]) => {
|
|
78
|
-
const [head = '', limit = '', activate = ''] = key.split('|');
|
|
81
|
+
const [head = '', limit = '', activate = '', paid = ''] = key.split('|');
|
|
79
82
|
const after = `${limit}${activate}`;
|
|
80
|
-
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
83
|
+
return `${head} ${listed(whats)}${paid}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
81
84
|
}),
|
|
82
85
|
...(shown.some((r) => r.match.any &&
|
|
83
86
|
r.geography !== 'foreign_only' &&
|
|
87
|
+
r.requires?.payment === undefined &&
|
|
84
88
|
r.rate > card.base_rate)
|
|
85
89
|
? []
|
|
86
90
|
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
@@ -200,6 +204,7 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
200
204
|
rotationGap: rotationGap(card, today),
|
|
201
205
|
caps: capSummaries(catalog, card, held, today),
|
|
202
206
|
milestones: milestoneSummaries(card, held, today),
|
|
207
|
+
credits: creditSummaries(catalog, card, held, today),
|
|
203
208
|
chosen: held.chosen
|
|
204
209
|
? {
|
|
205
210
|
period: held.chosen.period,
|
|
@@ -229,6 +234,29 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
229
234
|
cards,
|
|
230
235
|
};
|
|
231
236
|
}
|
|
237
|
+
function creditSummaries(catalog, card, held, today) {
|
|
238
|
+
return creditStates(catalog, card, held, today).map((s) => {
|
|
239
|
+
const status = !s.enrolled
|
|
240
|
+
? `${ENROLL_FIRST}, then mark it enrolled`
|
|
241
|
+
: s.left === null
|
|
242
|
+
? "give the card's anniversary to track it"
|
|
243
|
+
: `${creditLeft(s.left, s.period)}${s.until === null ? '' : `, through ${shortDate(s.until)}`}`;
|
|
244
|
+
return {
|
|
245
|
+
id: s.credit.id,
|
|
246
|
+
name: s.name,
|
|
247
|
+
amount: s.amount,
|
|
248
|
+
period: s.period,
|
|
249
|
+
enroll: s.credit.enroll === true,
|
|
250
|
+
enrolled: s.enrolled,
|
|
251
|
+
currentPeriod: s.key,
|
|
252
|
+
until: s.until,
|
|
253
|
+
used: s.used,
|
|
254
|
+
left: s.left,
|
|
255
|
+
tracked: held.credit_usage?.[s.credit.id] !== undefined,
|
|
256
|
+
text: `${creditBack(s)} ${creditWhere(catalog, s.credit)}: ${status}.`,
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
232
260
|
function introSummary(card, held, today) {
|
|
233
261
|
const [first] = card.intro_rates;
|
|
234
262
|
if (!first)
|
package/dist/format/card-text.js
CHANGED
|
@@ -9,11 +9,12 @@ export function cardText(catalog) {
|
|
|
9
9
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
10
10
|
function shortName(id) {
|
|
11
11
|
const name = cards.get(id)?.name ?? id;
|
|
12
|
-
return name
|
|
12
|
+
return (name
|
|
13
13
|
.replace(/^American Express /, 'Amex ')
|
|
14
14
|
.replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
|
|
15
|
-
|
|
16
|
-
.replace(/
|
|
15
|
+
// "Card" is part of Apple Card's name, not a suffix.
|
|
16
|
+
.replace(/ (Visa Signature |Visa Infinite )?(Credit )?(?<!^Apple )Card$/, '')
|
|
17
|
+
.replace(/ Card by /, ' by '));
|
|
17
18
|
}
|
|
18
19
|
function feeLabel(id) {
|
|
19
20
|
const card = cards.get(id);
|
package/dist/format/cards.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { membershipName } from "../engine/memberships.js";
|
|
1
2
|
import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
|
-
export function formatCardInfo(card) {
|
|
3
|
+
export function formatCardInfo(catalog, card) {
|
|
3
4
|
const fee = card.annualFee === null
|
|
4
5
|
? ''
|
|
5
6
|
: card.annualFee === 0
|
|
@@ -13,7 +14,9 @@ export function formatCardInfo(card) {
|
|
|
13
14
|
]
|
|
14
15
|
: []),
|
|
15
16
|
...(card.memberships.length > 0
|
|
16
|
-
? [
|
|
17
|
+
? [
|
|
18
|
+
`rates that need a membership: ${card.memberships.map((id) => `${membershipName(catalog, id)} (membership id ${id})`).join(', ')}`,
|
|
19
|
+
]
|
|
17
20
|
: []),
|
|
18
21
|
...(card.activation.length > 0
|
|
19
22
|
? [`activate with the issuer: ${card.activation.join(', ')}`]
|
|
@@ -21,6 +24,7 @@ export function formatCardInfo(card) {
|
|
|
21
24
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
25
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
23
26
|
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
27
|
+
...card.credits.map((c) => `merchant credit ${c.id}: ${c.text}`),
|
|
24
28
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
25
29
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
26
30
|
];
|
package/dist/format/explore.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
-
import { choiceName, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
2
|
+
import { capitalize, choiceName, CREDIT_GROUP, CREDITS_LEFT_OUT, ENROLL_FIRST, hostname, feeText, dollarsAndCents, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
3
|
const membershipFees = (memberships) => memberships
|
|
4
4
|
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
5
|
.join('');
|
|
@@ -15,7 +15,23 @@ export function formatExploration(r, catalog, all) {
|
|
|
15
15
|
return `No cards match at ${title}.\n`;
|
|
16
16
|
const shown = all ? r.ranked : r.ranked.slice(0, TOP_BEST);
|
|
17
17
|
const width = Math.max(...shown.map((e) => e.cardName.length));
|
|
18
|
-
const lines = [
|
|
18
|
+
const lines = [
|
|
19
|
+
...(r.credits.length === 0
|
|
20
|
+
? []
|
|
21
|
+
: [
|
|
22
|
+
`${CREDIT_GROUP} at ${title}:`,
|
|
23
|
+
'',
|
|
24
|
+
...r.credits.flatMap((c) => [
|
|
25
|
+
`- ${c.cardName}: ${c.text}${c.enroll ? ` (${ENROLL_FIRST})` : ''}. ${capitalize(feeText(c))}.`,
|
|
26
|
+
...(c.evidence
|
|
27
|
+
? [` "${c.evidence.quote}" (${hostname(c.evidence.url)})`]
|
|
28
|
+
: []),
|
|
29
|
+
]),
|
|
30
|
+
'',
|
|
31
|
+
]),
|
|
32
|
+
`Best cards for ${title}${r.credits.length === 0 ? '' : ' by rate'}:`,
|
|
33
|
+
'',
|
|
34
|
+
];
|
|
19
35
|
shown.forEach((e, i) => {
|
|
20
36
|
const earned = e.earned === null ? '' : ` ${dollarsAndCents(e.earned)}`;
|
|
21
37
|
lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
|
|
@@ -89,7 +105,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
89
105
|
]);
|
|
90
106
|
if (notes.size > 0)
|
|
91
107
|
lines.push('', ...notes);
|
|
92
|
-
lines.push('',
|
|
108
|
+
lines.push('', `Assumes each card's best picks, activations done each quarter, and no welcome bonuses. ${CREDITS_LEFT_OUT}`);
|
|
93
109
|
return `${lines.join('\n')}\n`;
|
|
94
110
|
}
|
|
95
111
|
export function formatAdditions(r, catalog, all) {
|
|
@@ -117,6 +133,6 @@ export function formatAdditions(r, catalog, all) {
|
|
|
117
133
|
]);
|
|
118
134
|
if (notes.size > 0)
|
|
119
135
|
lines.push('', ...notes);
|
|
120
|
-
lines.push('',
|
|
136
|
+
lines.push('', `Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. ${CREDITS_LEFT_OUT} Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.`);
|
|
121
137
|
return `${lines.join('\n')}\n`;
|
|
122
138
|
}
|
|
@@ -33,7 +33,8 @@ function cardLines(cards, style, bonusPick) {
|
|
|
33
33
|
const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
|
|
34
34
|
(c.milestoneBonus === null
|
|
35
35
|
? ''
|
|
36
|
-
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`)
|
|
36
|
+
: ` + ${money(c.milestoneBonus.dollars)} spend bonus`) +
|
|
37
|
+
(c.credit?.dollars ? ` + ${money(c.credit.dollars)} credit` : '');
|
|
37
38
|
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
38
39
|
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
39
40
|
for (const r of c.reasoning)
|
|
@@ -59,9 +60,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
59
60
|
const milestone = top.milestoneBonus === null
|
|
60
61
|
? ''
|
|
61
62
|
: `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
|
|
63
|
+
const credit = top.credit?.dollars
|
|
64
|
+
? `, plus ${money(top.credit.dollars)} back from its ${top.credit.name} credit`
|
|
65
|
+
: '';
|
|
62
66
|
const confidence = rec.decision.confidence;
|
|
63
67
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
64
|
-
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
|
|
68
|
+
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}${credit}.`)} ${capitalize(confidence)} confidence.`);
|
|
65
69
|
if (rec.bonus && rec.notes[0])
|
|
66
70
|
lines.push(rec.notes[0]);
|
|
67
71
|
}
|