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/evaluate.js
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
|
+
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
|
+
import { EPSILON } from "./compare.js";
|
|
1
3
|
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
2
4
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
|
+
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
|
+
export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
3
7
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
8
|
+
const payment = purchase.payment ?? null;
|
|
4
9
|
return {
|
|
5
10
|
category,
|
|
6
|
-
|
|
11
|
+
merchantIds: merchantFamily(catalog, merchant.merchantId),
|
|
7
12
|
mcc: category === merchant.category && merchant.likelyMcc !== null
|
|
8
13
|
? merchant.likelyMcc
|
|
9
14
|
: singleMcc(catalog, category),
|
|
10
|
-
channel: merchant.channel,
|
|
15
|
+
channel: paymentChannel(merchant.channel, payment),
|
|
11
16
|
country: purchase.country,
|
|
12
17
|
date: purchase.date,
|
|
13
18
|
amount: purchase.amount,
|
|
14
19
|
memberships: new Set(wallet.memberships),
|
|
20
|
+
payment,
|
|
15
21
|
};
|
|
16
22
|
}
|
|
17
23
|
// A category with one typical MCC (travel agencies are 4722) implies it, the
|
|
@@ -79,18 +85,16 @@ export function isActivated(card, held, ruleId) {
|
|
|
79
85
|
function applicability(card, rule, ctx, held, picked) {
|
|
80
86
|
const { match, exclude } = rule;
|
|
81
87
|
const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
|
|
88
|
+
const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
|
|
82
89
|
if (match.categories?.length && !inCategory(match.categories))
|
|
83
90
|
return 'no';
|
|
84
|
-
if (match.merchants?.length &&
|
|
85
|
-
!(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
|
|
91
|
+
if (match.merchants?.length && !named(match.merchants))
|
|
86
92
|
return 'no';
|
|
87
|
-
}
|
|
88
93
|
if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
|
|
89
94
|
return 'no';
|
|
90
95
|
if (match.choice !== undefined) {
|
|
91
96
|
const covered = picked.get(match.choice);
|
|
92
|
-
const merchantPicked =
|
|
93
|
-
(covered?.merchants.includes(ctx.merchantId) ?? false);
|
|
97
|
+
const merchantPicked = named(covered?.merchants);
|
|
94
98
|
const onlinePicked = ctx.channel === 'online' &&
|
|
95
99
|
(covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
|
|
96
100
|
if (!merchantPicked &&
|
|
@@ -100,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
100
104
|
}
|
|
101
105
|
if (exclude?.categories && inCategory(exclude.categories))
|
|
102
106
|
return 'no';
|
|
103
|
-
if (exclude?.merchants
|
|
104
|
-
ctx.merchantId !== null &&
|
|
105
|
-
exclude.merchants.includes(ctx.merchantId)) {
|
|
107
|
+
if (named(exclude?.merchants))
|
|
106
108
|
return 'no';
|
|
107
|
-
}
|
|
108
109
|
if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
|
|
109
110
|
return 'no';
|
|
111
|
+
if (exclude?.payment &&
|
|
112
|
+
ctx.payment !== null &&
|
|
113
|
+
exclude.payment.includes(ctx.payment))
|
|
114
|
+
return 'no';
|
|
110
115
|
if (rule.channel !== 'any' && rule.channel !== ctx.channel)
|
|
111
116
|
return 'no';
|
|
112
117
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
@@ -118,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
118
123
|
const membership = rule.requires?.membership;
|
|
119
124
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
120
125
|
return 'no';
|
|
126
|
+
const payment = rule.requires?.payment;
|
|
127
|
+
if (payment !== undefined && ctx.payment !== payment)
|
|
128
|
+
return 'no';
|
|
121
129
|
if (rule.requires?.activation && !isActivated(card, held, rule.id))
|
|
122
130
|
return 'needs-activation';
|
|
123
131
|
return 'applies';
|
|
@@ -222,3 +230,22 @@ export function evaluateCard(card, held, ctx) {
|
|
|
222
230
|
staleChoice,
|
|
223
231
|
};
|
|
224
232
|
}
|
|
233
|
+
// The way of paying (one a rule on the card requires) that would earn the
|
|
234
|
+
// most on this purchase, when that beats `rate`. Only while the purchase
|
|
235
|
+
// doesn't say how it's paid.
|
|
236
|
+
export function paymentUpgrade(card, held, ctx, rate) {
|
|
237
|
+
if (ctx.payment !== null)
|
|
238
|
+
return null;
|
|
239
|
+
let best = null;
|
|
240
|
+
const methods = new Set(card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]));
|
|
241
|
+
for (const payment of methods) {
|
|
242
|
+
const paid = evaluateCard(card, held, {
|
|
243
|
+
...ctx,
|
|
244
|
+
payment,
|
|
245
|
+
channel: paymentChannel(ctx.channel, payment),
|
|
246
|
+
}).best;
|
|
247
|
+
if (paid.rate > (best?.rate ?? rate) + EPSILON)
|
|
248
|
+
best = { payment, rate: paid.rate, nominalRate: paid.nominalRate };
|
|
249
|
+
}
|
|
250
|
+
return best;
|
|
251
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
|
-
import {
|
|
2
|
+
import { creditCovers, creditPerYear } from "./credits.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { creditName } from "./rule-text.js";
|
|
3
5
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
|
-
import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
|
|
7
|
+
import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./recommend.js";
|
|
6
8
|
import { rotationGap } from "./rotation.js";
|
|
7
9
|
import { membershipsUsed } from "./setup.js";
|
|
8
10
|
import { optionName } from "./rule-text.js";
|
|
9
|
-
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
11
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
12
|
import { cardValuation } from "./valuation.js";
|
|
11
13
|
import { EPSILON } from "./compare.js";
|
|
12
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -129,7 +131,7 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
|
|
|
129
131
|
memberships: [...options.memberships, membership],
|
|
130
132
|
});
|
|
131
133
|
const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
|
|
132
|
-
const fee =
|
|
134
|
+
const fee = foreignFeeFor(card, purchase);
|
|
133
135
|
const pct = evaluateCard(card, held, ctx).best.rate *
|
|
134
136
|
cardValuation(catalog, wallet, card).centsPerUnit -
|
|
135
137
|
fee;
|
|
@@ -163,11 +165,13 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
163
165
|
channel: query.channel,
|
|
164
166
|
date: query.date,
|
|
165
167
|
country: query.country ?? 'US',
|
|
168
|
+
payment: query.payment ?? null,
|
|
166
169
|
};
|
|
167
170
|
const ranked = [];
|
|
171
|
+
const ifPaid = new Map();
|
|
168
172
|
const excluded = [];
|
|
169
173
|
// A Costco purchase means a Costco member: no membership to point out.
|
|
170
|
-
const
|
|
174
|
+
const members = paidMemberships(catalog, {
|
|
171
175
|
memberships: options.memberships,
|
|
172
176
|
merchantIds: [merchant.merchantId],
|
|
173
177
|
});
|
|
@@ -177,7 +181,9 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
177
181
|
const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
|
|
178
182
|
const held = idealHolding(card, query.date, picks);
|
|
179
183
|
const wallet = walletOf(held, options);
|
|
180
|
-
const rec = recommend(catalog, wallet, purchase, merchant
|
|
184
|
+
const rec = recommend(catalog, wallet, purchase, merchant, {
|
|
185
|
+
credits: false,
|
|
186
|
+
});
|
|
181
187
|
const top = rec.ranked[0];
|
|
182
188
|
if (!top) {
|
|
183
189
|
excluded.push(...rec.excluded);
|
|
@@ -185,6 +191,16 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
185
191
|
}
|
|
186
192
|
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
187
193
|
const gap = rotationGap(card, query.date);
|
|
194
|
+
const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
195
|
+
if (paid)
|
|
196
|
+
ifPaid.set(card.id, {
|
|
197
|
+
payment: paid.payment,
|
|
198
|
+
earn: earnText(catalog, paid.nominalRate, top.currency),
|
|
199
|
+
standard: earnText(catalog, top.rate, top.currency),
|
|
200
|
+
returnPct: paid.rate * top.centsPerUnit - (top.foreignFee ?? 0),
|
|
201
|
+
});
|
|
202
|
+
const payment = card.rules.find((r) => r.id === top.matchedRule)?.requires
|
|
203
|
+
?.payment;
|
|
188
204
|
const evidenceRule = top.matchedRule ?? 'base_rate';
|
|
189
205
|
const evidence = card.evidence.find((e) => e.rule === evidenceRule);
|
|
190
206
|
ranked.push({
|
|
@@ -203,7 +219,8 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
203
219
|
requirements,
|
|
204
220
|
conditions: [
|
|
205
221
|
...conditionsText(catalog, card, requirements),
|
|
206
|
-
...
|
|
222
|
+
...(payment === undefined ? [] : [`Paid ${PAYMENT_PHRASES[payment]}.`]),
|
|
223
|
+
...membershipNeeded(catalog, card, members),
|
|
207
224
|
],
|
|
208
225
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
209
226
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
@@ -212,13 +229,48 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
212
229
|
ranked.sort(compareExplored);
|
|
213
230
|
return {
|
|
214
231
|
merchant,
|
|
232
|
+
credits: creditPicks(catalog, merchant, ranked),
|
|
215
233
|
ranked,
|
|
216
234
|
excluded,
|
|
217
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
|
|
235
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
|
|
218
236
|
warnings: exploreWarnings(catalog, merchant),
|
|
219
237
|
};
|
|
220
238
|
}
|
|
221
|
-
function
|
|
239
|
+
function creditPicks(catalog, merchant, ranked) {
|
|
240
|
+
const picks = ranked.flatMap((entry) => {
|
|
241
|
+
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
242
|
+
return ((card?.credits ?? [])
|
|
243
|
+
// A credit for a whole category (every restaurant) isn't a store's.
|
|
244
|
+
.filter((c) => c.match.merchants !== undefined &&
|
|
245
|
+
creditCovers(catalog, c, merchant))
|
|
246
|
+
.map((credit) => {
|
|
247
|
+
const earn = earnText(catalog, entry.rate, entry.currency);
|
|
248
|
+
const evidence = card?.evidence.find((e) => e.rule === credit.id);
|
|
249
|
+
return {
|
|
250
|
+
cardId: entry.cardId,
|
|
251
|
+
cardName: entry.cardName,
|
|
252
|
+
issuer: entry.issuer,
|
|
253
|
+
network: entry.network,
|
|
254
|
+
annualFee: entry.annualFee,
|
|
255
|
+
firstYearFee: entry.firstYearFee,
|
|
256
|
+
creditId: credit.id,
|
|
257
|
+
name: creditName(catalog, credit),
|
|
258
|
+
amount: credit.amount,
|
|
259
|
+
period: credit.period,
|
|
260
|
+
enroll: credit.enroll === true,
|
|
261
|
+
note: credit.note ?? null,
|
|
262
|
+
back: creditBack(credit),
|
|
263
|
+
earn,
|
|
264
|
+
text: creditThen(credit, earn),
|
|
265
|
+
evidence: evidence
|
|
266
|
+
? { quote: evidence.quote, url: evidence.url }
|
|
267
|
+
: null,
|
|
268
|
+
};
|
|
269
|
+
}));
|
|
270
|
+
});
|
|
271
|
+
return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
|
|
272
|
+
}
|
|
273
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
|
|
222
274
|
const assumptions = [];
|
|
223
275
|
if (merchant.source === 'category') {
|
|
224
276
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -235,6 +287,17 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
|
|
|
235
287
|
assumptions.push(values);
|
|
236
288
|
if (isForeign(purchase))
|
|
237
289
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
290
|
+
if (purchase.payment)
|
|
291
|
+
assumptions.push(paidText(purchase.payment));
|
|
292
|
+
const top = ranked[0];
|
|
293
|
+
for (const entry of ranked) {
|
|
294
|
+
const paid = ifPaid.get(entry.cardId);
|
|
295
|
+
if (!paid || !top)
|
|
296
|
+
continue;
|
|
297
|
+
if (entry !== top && paid.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
298
|
+
continue;
|
|
299
|
+
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
300
|
+
}
|
|
238
301
|
return assumptions;
|
|
239
302
|
}
|
|
240
303
|
function exploreWarnings(catalog, merchant) {
|
package/dist/engine/offers.js
CHANGED
|
@@ -2,7 +2,7 @@ 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 { money, roundCents } from "./text.js";
|
|
5
|
+
import { CREDITS_LEFT_OUT, 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
|
|
@@ -83,6 +83,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
83
83
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
84
84
|
`Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
|
|
85
85
|
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
|
|
86
|
+
CREDITS_LEFT_OUT,
|
|
86
87
|
];
|
|
87
88
|
return { offers, withoutOffer, notes };
|
|
88
89
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Keys match the wallet's PeriodKey format: "2026"
|
|
2
|
-
// year is keyed by the calendar year it
|
|
3
|
-
// cardmember year is asked for without an
|
|
1
|
+
// Keys match the wallet's PeriodKey format: "2026", "2026-Q4" or "2026-H2"
|
|
2
|
+
// (July to December). A cardmember year is keyed by the calendar year it
|
|
3
|
+
// started in. Returns null when a cardmember year is asked for without an
|
|
4
|
+
// anniversary (MM-DD).
|
|
4
5
|
export function periodKey(period, date, anniversary) {
|
|
5
6
|
const year = Number(date.slice(0, 4));
|
|
6
7
|
const month = Number(date.slice(5, 7));
|
|
@@ -11,6 +12,8 @@ export function periodKey(period, date, anniversary) {
|
|
|
11
12
|
return String(year);
|
|
12
13
|
case 'quarter':
|
|
13
14
|
return `${String(year)}-Q${String(Math.floor((month - 1) / 3) + 1)}`;
|
|
15
|
+
case 'half_year':
|
|
16
|
+
return `${String(year)}-H${month <= 6 ? '1' : '2'}`;
|
|
14
17
|
case 'cardmember_year':
|
|
15
18
|
if (anniversary === null)
|
|
16
19
|
return null;
|
|
@@ -57,6 +60,10 @@ export function periodEnd(period, key, anniversary) {
|
|
|
57
60
|
const q = /^(\d{4})-Q([1-4])$/.exec(key);
|
|
58
61
|
return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
|
|
59
62
|
}
|
|
63
|
+
case 'half_year': {
|
|
64
|
+
const h = /^(\d{4})-H([12])$/.exec(key);
|
|
65
|
+
return h ? utcDay(Number(h[1]), Number(h[2]) * 6 + 1, 0) : null;
|
|
66
|
+
}
|
|
60
67
|
case 'calendar_year':
|
|
61
68
|
return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
|
|
62
69
|
case 'cardmember_year': {
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
-
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
-
import { openedOn } from "./intro.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
+
import { introWindows, openedOn, withIntro } from "./intro.js";
|
|
5
5
|
import { membershipName } from "./memberships.js";
|
|
6
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
7
7
|
import { daysBetween } from "./periods.js";
|
|
8
|
+
import { creditAt, creditDollars } from "./credits.js";
|
|
8
9
|
import { ruleWhat } from "./rule-text.js";
|
|
9
10
|
import { rotationGap } from "./rotation.js";
|
|
10
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
11
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
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
13
|
import { cardValuation } from "./valuation.js";
|
|
13
14
|
export const STALE_AFTER_DAYS = 180;
|
|
14
15
|
// The engine only tells the U.S. from everywhere else.
|
|
15
16
|
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
16
17
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
18
|
+
// A fee that isn't on file counts as the usual 3%, so a card can't win
|
|
19
|
+
// abroad on a guess that it charges none.
|
|
20
|
+
export const ASSUMED_FOREIGN_FEE = 3;
|
|
21
|
+
export const foreignFeeFor = (card, purchase) => isForeign(purchase)
|
|
22
|
+
? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
|
|
23
|
+
: 0;
|
|
17
24
|
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
18
25
|
// leaves unassigned, and nothing prints it.
|
|
19
26
|
export const ABROAD = 'ZZ';
|
|
@@ -42,7 +49,7 @@ function compareEntries(a, b) {
|
|
|
42
49
|
return aCapped ? 1 : -1;
|
|
43
50
|
return a.card.id.localeCompare(b.card.id);
|
|
44
51
|
}
|
|
45
|
-
function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
52
|
+
function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
|
|
46
53
|
const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
|
|
47
54
|
const entries = [];
|
|
48
55
|
const excluded = [];
|
|
@@ -78,8 +85,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
78
85
|
const bonusPoints = milestonePoints(milestones);
|
|
79
86
|
const perDollar = evaluation.best.rate +
|
|
80
87
|
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
81
|
-
|
|
82
|
-
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
88
|
+
const fee = foreignFeeFor(card, purchase);
|
|
83
89
|
const fresh = card.intro_rates.length > 0 && openedOn(held) === null
|
|
84
90
|
? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
|
|
85
91
|
: null;
|
|
@@ -92,6 +98,14 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
92
98
|
days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
|
|
93
99
|
}
|
|
94
100
|
: null;
|
|
101
|
+
const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
|
|
102
|
+
const credit = options.credits === false
|
|
103
|
+
? null
|
|
104
|
+
: creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
|
|
105
|
+
const back = credit ? creditDollars(credit, purchase.amount) : null;
|
|
106
|
+
const creditPct = back !== null && back > 0 && purchase.amount
|
|
107
|
+
? (back / purchase.amount) * 100
|
|
108
|
+
: 0;
|
|
95
109
|
entries.push({
|
|
96
110
|
card,
|
|
97
111
|
held,
|
|
@@ -100,10 +114,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
100
114
|
milestones,
|
|
101
115
|
perDollar,
|
|
102
116
|
fee,
|
|
103
|
-
|
|
117
|
+
credit,
|
|
118
|
+
creditDollars: back,
|
|
119
|
+
creditPct,
|
|
120
|
+
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
|
|
104
121
|
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
105
|
-
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
106
|
-
|
|
122
|
+
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
123
|
+
fee +
|
|
124
|
+
creditPct,
|
|
125
|
+
ifNew: ifNew && { ...ifNew, returnPct: ifNew.returnPct + creditPct },
|
|
126
|
+
ifPaid: paid
|
|
127
|
+
? {
|
|
128
|
+
payment: paid.payment,
|
|
129
|
+
rate: paid.nominalRate,
|
|
130
|
+
returnPct: (perDollar - evaluation.best.rate + paid.rate) *
|
|
131
|
+
valuation.centsPerUnit -
|
|
132
|
+
fee +
|
|
133
|
+
creditPct,
|
|
134
|
+
}
|
|
135
|
+
: null,
|
|
107
136
|
});
|
|
108
137
|
}
|
|
109
138
|
entries.sort(compareEntries);
|
|
@@ -114,18 +143,19 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
114
143
|
const { best } = evaluation;
|
|
115
144
|
const lines = [];
|
|
116
145
|
const currency = valuation.currency;
|
|
146
|
+
// The standard rate first; a first-year rate on top of it is its own line.
|
|
147
|
+
// The intro's terms stay in search_cards, not in every answer.
|
|
148
|
+
const [intro] = best.intro;
|
|
149
|
+
const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
|
|
117
150
|
if (best.rule) {
|
|
118
151
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
119
|
-
lines.push(`${
|
|
152
|
+
lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
120
153
|
}
|
|
121
154
|
else {
|
|
122
|
-
lines.push(`${
|
|
123
|
-
}
|
|
124
|
-
const [intro] = best.intro;
|
|
125
|
-
if (intro) {
|
|
126
|
-
const note = intro.intro.note ? ` ${intro.intro.note}` : '';
|
|
127
|
-
lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
|
|
155
|
+
lines.push(`${standard}, its base rate. No bonus category applies here.`);
|
|
128
156
|
}
|
|
157
|
+
if (intro)
|
|
158
|
+
lines.push(introRateText(earnText(catalog, best.nominalRate, currency), standard, intro.intro.days, intro.until));
|
|
129
159
|
const cap = best.cap;
|
|
130
160
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
131
161
|
const over = amount - cap.remaining;
|
|
@@ -133,6 +163,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
133
163
|
}
|
|
134
164
|
for (const hit of entry.milestones)
|
|
135
165
|
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
166
|
+
if (entry.credit)
|
|
167
|
+
lines.push(creditAnswerText(entry.credit, entry.creditDollars));
|
|
136
168
|
if (valuation.pooledFrom !== null) {
|
|
137
169
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
138
170
|
const heldPartners = wallet.cards
|
|
@@ -151,10 +183,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
151
183
|
const { card, valuation, evaluation } = entry;
|
|
152
184
|
const { best, nominal } = evaluation;
|
|
153
185
|
const warnings = [];
|
|
154
|
-
if (entry.fee > 0)
|
|
155
|
-
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
156
186
|
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
157
|
-
warnings.push(
|
|
187
|
+
warnings.push(`Its foreign transaction fee isn't on file, so earnrate assumes the usual ${pct(entry.fee)} and it nets ${pct(entry.rateReturnPct)}. Check the issuer's terms before using it abroad.`);
|
|
188
|
+
else if (entry.fee > 0)
|
|
189
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
158
190
|
const capLabel = (e) => e.cap
|
|
159
191
|
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
160
192
|
: '';
|
|
@@ -186,12 +218,14 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
186
218
|
if (evaluation.staleChoice !== null) {
|
|
187
219
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
188
220
|
}
|
|
221
|
+
const windows = introWindows(card, entry.held, purchase.date);
|
|
189
222
|
for (const rule of evaluation.needsActivation) {
|
|
223
|
+
const rate = earnText(catalog, withIntro(rule.rate, rule.id, windows), valuation.currency);
|
|
190
224
|
if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
|
|
191
|
-
warnings.push(`The bonus here (${
|
|
225
|
+
warnings.push(`The bonus here (${rate}) needed activating by ${shortDate(rule.activate_by)}.`);
|
|
192
226
|
continue;
|
|
193
227
|
}
|
|
194
|
-
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${
|
|
228
|
+
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${rate} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
195
229
|
}
|
|
196
230
|
return warnings;
|
|
197
231
|
}
|
|
@@ -218,6 +252,16 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
218
252
|
entry.valuation.centsPerUnit) /
|
|
219
253
|
100),
|
|
220
254
|
},
|
|
255
|
+
credit: entry.credit && {
|
|
256
|
+
id: entry.credit.credit.id,
|
|
257
|
+
name: entry.credit.name,
|
|
258
|
+
amount: entry.credit.amount,
|
|
259
|
+
period: entry.credit.period,
|
|
260
|
+
enrolled: entry.credit.enrolled,
|
|
261
|
+
left: entry.credit.left,
|
|
262
|
+
until: entry.credit.until,
|
|
263
|
+
dollars: entry.creditDollars,
|
|
264
|
+
},
|
|
221
265
|
capped: evaluation.best.cap !== null,
|
|
222
266
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
223
267
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -262,15 +306,16 @@ function sensitivity(catalog, entries) {
|
|
|
262
306
|
continue;
|
|
263
307
|
const wRate = winner.perDollar;
|
|
264
308
|
const oRate = other.perDollar;
|
|
265
|
-
// Net return is rate × value − fee, so each flip point adds the
|
|
309
|
+
// Net return is rate × value − fee + credit, so each flip point adds the
|
|
310
|
+
// fee back and takes the credit off.
|
|
266
311
|
if (w.range && wRate > 0) {
|
|
267
|
-
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
312
|
+
const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
|
|
268
313
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
269
314
|
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)}¢).`);
|
|
270
315
|
}
|
|
271
316
|
}
|
|
272
317
|
if (o.range && oRate > 0) {
|
|
273
|
-
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
318
|
+
const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
|
|
274
319
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
275
320
|
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)}¢).`);
|
|
276
321
|
}
|
|
@@ -292,8 +337,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
292
337
|
const naming = entries.flatMap((e) => {
|
|
293
338
|
const rule = e.evaluation.best.rule;
|
|
294
339
|
return rule &&
|
|
295
|
-
merchant.merchantId
|
|
296
|
-
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
340
|
+
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
297
341
|
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
298
342
|
: [];
|
|
299
343
|
});
|
|
@@ -310,6 +354,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
310
354
|
if (purchase.channel === null && merchant.channel === 'in_store') {
|
|
311
355
|
assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
|
|
312
356
|
}
|
|
357
|
+
if (merchant.channel === null && purchase.payment === 'paypal') {
|
|
358
|
+
assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
|
|
359
|
+
}
|
|
313
360
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
314
361
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
315
362
|
}
|
|
@@ -318,7 +365,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
318
365
|
assumptions.push(values);
|
|
319
366
|
if (isForeign(purchase))
|
|
320
367
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
368
|
+
if (purchase.payment)
|
|
369
|
+
assumptions.push(paidText(purchase.payment));
|
|
321
370
|
const winner = entries[0];
|
|
371
|
+
for (const e of entries) {
|
|
372
|
+
if (!e.ifPaid || !winner)
|
|
373
|
+
continue;
|
|
374
|
+
if (e !== winner &&
|
|
375
|
+
e.ifPaid.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
376
|
+
continue;
|
|
377
|
+
const currency = e.valuation.currency;
|
|
378
|
+
assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
|
|
379
|
+
}
|
|
322
380
|
for (const e of entries) {
|
|
323
381
|
if (!e.ifNew || !winner)
|
|
324
382
|
continue;
|
|
@@ -333,15 +391,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
333
391
|
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.`);
|
|
334
392
|
return assumptions;
|
|
335
393
|
}
|
|
336
|
-
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
337
|
-
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category);
|
|
394
|
+
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel), options = {}) {
|
|
395
|
+
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category, options);
|
|
338
396
|
const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
|
|
339
397
|
const winner = entries[0];
|
|
340
398
|
const warnings = [];
|
|
341
399
|
const notes = [];
|
|
342
|
-
const charging = entries.filter((e) => e.fee > 0);
|
|
400
|
+
const charging = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee !== null);
|
|
401
|
+
const assumed = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee === null);
|
|
343
402
|
if (charging.length > 0 && entries.length > 1)
|
|
344
403
|
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.`);
|
|
404
|
+
if (assumed.length > 0 && entries.length > 1)
|
|
405
|
+
notes.push(`${listed(assumed.map(displayName))} ${assumed.length === 1 ? 'has' : 'have'} no foreign transaction fee on file, so the usual ${pct(ASSUMED_FOREIGN_FEE)} is subtracted.`);
|
|
345
406
|
if (merchant.source === 'none') {
|
|
346
407
|
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.`);
|
|
347
408
|
}
|
|
@@ -358,7 +419,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
358
419
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
359
420
|
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
360
421
|
for (const category of merchant.ambiguity.mayCodeAs) {
|
|
361
|
-
const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
|
|
422
|
+
const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
|
|
362
423
|
const alt = alts[0];
|
|
363
424
|
if (!alt || !winner)
|
|
364
425
|
continue;
|
|
@@ -385,7 +446,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
385
446
|
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
386
447
|
: null;
|
|
387
448
|
const alt = online
|
|
388
|
-
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
449
|
+
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
|
|
389
450
|
: undefined;
|
|
390
451
|
if (alt)
|
|
391
452
|
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
|
|
@@ -403,15 +464,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
403
464
|
const gap = card ? rotationGap(card, purchase.date) : null;
|
|
404
465
|
if (card && gap !== null) {
|
|
405
466
|
const name = held.checkout_label ?? card.name;
|
|
406
|
-
warnings.push(`${name}
|
|
467
|
+
warnings.push(`${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
|
|
407
468
|
}
|
|
408
469
|
}
|
|
409
470
|
for (const id of merchant.bookedVia) {
|
|
410
471
|
const via = resolveMerchantById(catalog, id, purchase.channel);
|
|
411
472
|
if (!via || !winner)
|
|
412
473
|
continue;
|
|
413
|
-
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
414
|
-
.entries[0];
|
|
474
|
+
const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
|
|
415
475
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
416
476
|
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
|
|
417
477
|
}
|
|
@@ -447,8 +507,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
447
507
|
const costHere = ties || top.earned === null || first.earned === null
|
|
448
508
|
? null
|
|
449
509
|
: roundCents(first.earned +
|
|
450
|
-
(first.milestoneBonus?.dollars ?? 0)
|
|
451
|
-
(
|
|
510
|
+
(first.milestoneBonus?.dollars ?? 0) +
|
|
511
|
+
(first.credit?.dollars ?? 0) -
|
|
512
|
+
(top.earned +
|
|
513
|
+
(top.milestoneBonus?.dollars ?? 0) +
|
|
514
|
+
(top.credit?.dollars ?? 0)));
|
|
452
515
|
bonusResult = {
|
|
453
516
|
cardId: top.cardId,
|
|
454
517
|
checkoutLabel: top.checkoutLabel,
|
|
@@ -464,7 +527,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
464
527
|
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
465
528
|
notes.unshift(ties
|
|
466
529
|
? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
|
|
467
|
-
: `You're behind pace on ${displayName(bonus.entry)}
|
|
530
|
+
: `You're behind pace on ${possessive(displayName(bonus.entry))} welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
468
531
|
? ''
|
|
469
532
|
: costHere === null
|
|
470
533
|
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|