earnrate 0.2.2 → 0.2.4
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 +13 -12
- package/data/CHANGELOG.md +35 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +15 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +15 -1
- package/data/merchants/overrides.yaml +12 -1
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +1 -1
- package/dist/cli/explore.js +1 -1
- package/dist/cli/import.js +4 -3
- package/dist/cli/run.js +1 -1
- package/dist/data/file-store.js +3 -1
- package/dist/data/integrity.js +10 -0
- package/dist/data/store.js +9 -1
- package/dist/data/wallet-edit.js +8 -2
- package/dist/engine/cheat-sheet.js +44 -2
- package/dist/engine/explore.js +14 -5
- package/dist/engine/memberships.js +45 -0
- package/dist/engine/offers.js +22 -2
- package/dist/engine/recommend.js +91 -31
- package/dist/engine/setup.js +23 -4
- package/dist/engine/simulate.js +32 -10
- package/dist/engine/text.js +51 -0
- package/dist/engine/wallet-change.js +27 -7
- package/dist/engine/wallet-summary.js +133 -3
- package/dist/format/card-text.js +7 -13
- package/dist/format/cards.js +7 -8
- package/dist/format/explore.js +17 -8
- package/dist/format/offers.js +5 -9
- package/dist/format/recommendation.js +11 -5
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +21 -18
- package/dist/mcp/context.js +7 -3
- package/dist/mcp/inputs.js +2 -2
- package/dist/mcp/schemas.js +18 -0
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/tools/account.js +55 -7
- package/dist/mcp/tools/answers.js +24 -7
- package/dist/mcp/tools/wallet.js +49 -7
- package/dist/schema/card.js +14 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
2
|
import { categoryResolution, exploreCards, } from "./explore.js";
|
|
3
|
+
import { bonusStatus } from "./bonus.js";
|
|
3
4
|
import { periodKey } from "./periods.js";
|
|
4
5
|
import { recommend } from "./recommend.js";
|
|
5
6
|
import { setupNotes } from "./setup.js";
|
|
7
|
+
import { listed, money, shortDate } from "./text.js";
|
|
6
8
|
const CHEAT_SHEET_ITEMS = [
|
|
7
9
|
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
8
10
|
{ label: 'Dining', category: 'dining' },
|
|
@@ -17,6 +19,7 @@ const CHEAT_SHEET_ITEMS = [
|
|
|
17
19
|
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
18
20
|
// with caps, picks and activations as the wallet has them.
|
|
19
21
|
export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
22
|
+
const ranked = [];
|
|
20
23
|
const rows = items.map((item) => {
|
|
21
24
|
const merchant = 'merchant' in item
|
|
22
25
|
? resolveMerchantById(catalog, item.merchant, null)
|
|
@@ -30,7 +33,10 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
30
33
|
date,
|
|
31
34
|
country: 'US',
|
|
32
35
|
}, merchant);
|
|
33
|
-
|
|
36
|
+
// The card that earns the most, even when a welcome bonus would take the
|
|
37
|
+
// pick for one purchase: the sheet is for the whole quarter.
|
|
38
|
+
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
39
|
+
ranked.push({ label: item.label, cards: rec.ranked });
|
|
34
40
|
return {
|
|
35
41
|
label: item.label,
|
|
36
42
|
cardId: top?.cardId ?? null,
|
|
@@ -40,8 +46,44 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
40
46
|
note: top?.warnings[0] ?? null,
|
|
41
47
|
};
|
|
42
48
|
});
|
|
49
|
+
const bonuses = wallet.cards.flatMap((held) => {
|
|
50
|
+
const status = bonusStatus(catalog, wallet, held, date);
|
|
51
|
+
if (status?.state !== 'open')
|
|
52
|
+
return [];
|
|
53
|
+
const name = held.checkout_label ??
|
|
54
|
+
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
55
|
+
held.id;
|
|
56
|
+
// Rows where the bonus card matches the row's card, or comes within a
|
|
57
|
+
// point and three quarters of its rate (as single answers mention it).
|
|
58
|
+
const matches = [];
|
|
59
|
+
const near = [];
|
|
60
|
+
for (const { label, cards } of ranked) {
|
|
61
|
+
const best = cards[0]?.effectiveReturnPct;
|
|
62
|
+
const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
|
|
63
|
+
if (best === undefined || own === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
if (best - own < 1e-9)
|
|
66
|
+
matches.push(label.toLowerCase());
|
|
67
|
+
else if (best - own <= 1 && own >= best * 0.75)
|
|
68
|
+
near.push(label.toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
const where = [
|
|
71
|
+
...(matches.length > 0
|
|
72
|
+
? [`matches the listed card on ${listed(matches)}`]
|
|
73
|
+
: []),
|
|
74
|
+
...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
|
|
75
|
+
];
|
|
76
|
+
return [
|
|
77
|
+
`Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
78
|
+
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
79
|
+
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
80
|
+
];
|
|
81
|
+
});
|
|
43
82
|
const notes = [
|
|
44
|
-
...new Set(
|
|
83
|
+
...new Set([
|
|
84
|
+
...bonuses,
|
|
85
|
+
...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
|
|
86
|
+
]),
|
|
45
87
|
];
|
|
46
88
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
47
89
|
return {
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
+
import { membershipNeeded, paidMemberships } from "./memberships.js";
|
|
3
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
4
5
|
import { recommend } from "./recommend.js";
|
|
5
6
|
import { rotationGap } from "./rotation.js";
|
|
6
7
|
import { membershipsUsed } from "./setup.js";
|
|
7
|
-
import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
|
|
8
|
+
import { categoryPhrase, choiceName, codesAs, earnText, membershipName, money, pointValuesText, words, } from "./text.js";
|
|
8
9
|
import { cardValuation } from "./valuation.js";
|
|
9
10
|
const EPSILON = 1e-9;
|
|
10
11
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -150,12 +151,12 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
150
151
|
function conditionsText(catalog, r) {
|
|
151
152
|
const conditions = [];
|
|
152
153
|
if (r.pick) {
|
|
153
|
-
conditions.push(`Needs
|
|
154
|
+
conditions.push(`Needs ${r.pick.options.map(words).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${PICK_PERIODS[r.pick.period]}).`);
|
|
154
155
|
}
|
|
155
156
|
if (r.activation !== null)
|
|
156
|
-
conditions.push(
|
|
157
|
+
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
157
158
|
if (r.membership !== null)
|
|
158
|
-
conditions.push(`Needs ${r.membership}.`);
|
|
159
|
+
conditions.push(`Needs ${membershipName(r.membership)}.`);
|
|
159
160
|
if (r.cap) {
|
|
160
161
|
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
|
|
161
162
|
}
|
|
@@ -206,6 +207,11 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
206
207
|
};
|
|
207
208
|
const ranked = [];
|
|
208
209
|
const excluded = [];
|
|
210
|
+
// A Costco purchase means a Costco member: no membership to point out.
|
|
211
|
+
const paid = paidMemberships(catalog, {
|
|
212
|
+
memberships: options.memberships,
|
|
213
|
+
merchantIds: [merchant.merchantId],
|
|
214
|
+
});
|
|
209
215
|
for (const card of catalog.cards) {
|
|
210
216
|
if (!matchesFilters(card, options.filters))
|
|
211
217
|
continue;
|
|
@@ -236,7 +242,10 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
236
242
|
firstYearFee: card.first_year_fee ?? null,
|
|
237
243
|
picks,
|
|
238
244
|
requirements,
|
|
239
|
-
conditions:
|
|
245
|
+
conditions: [
|
|
246
|
+
...conditionsText(catalog, requirements),
|
|
247
|
+
...membershipNeeded(card, paid),
|
|
248
|
+
],
|
|
240
249
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
241
250
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
242
251
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { money } from "./text.js";
|
|
2
|
+
// Memberships the person already pays for: the ones they've said, the ones a
|
|
3
|
+
// card they hold needs, and the ones their spending shows (only members shop
|
|
4
|
+
// at Costco).
|
|
5
|
+
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
6
|
+
const paid = new Set(memberships);
|
|
7
|
+
for (const card of catalog.cards) {
|
|
8
|
+
const m = card.requires_membership;
|
|
9
|
+
if (!m)
|
|
10
|
+
continue;
|
|
11
|
+
if (heldCards.includes(card.id) ||
|
|
12
|
+
m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
13
|
+
paid.add(m.id);
|
|
14
|
+
}
|
|
15
|
+
return paid;
|
|
16
|
+
}
|
|
17
|
+
// What these cards' memberships add, once each, leaving out the paid ones.
|
|
18
|
+
export function membershipCosts(cards, paid) {
|
|
19
|
+
const costs = new Map();
|
|
20
|
+
for (const card of cards) {
|
|
21
|
+
const m = card.requires_membership;
|
|
22
|
+
if (!m || paid.has(m.id))
|
|
23
|
+
continue;
|
|
24
|
+
const cost = costs.get(m.id) ?? {
|
|
25
|
+
id: m.id,
|
|
26
|
+
name: m.name,
|
|
27
|
+
annualFee: m.annual_fee,
|
|
28
|
+
cardIds: [],
|
|
29
|
+
};
|
|
30
|
+
cost.cardIds.push(card.id);
|
|
31
|
+
costs.set(m.id, cost);
|
|
32
|
+
}
|
|
33
|
+
return [...costs.values()];
|
|
34
|
+
}
|
|
35
|
+
// The card's membership as a condition, unless it's paid for.
|
|
36
|
+
export function membershipNeeded(card, paid) {
|
|
37
|
+
const m = card.requires_membership;
|
|
38
|
+
return m && !paid.has(m.id)
|
|
39
|
+
? [`Needs ${m.name} (${money(m.annual_fee)} a year).`]
|
|
40
|
+
: [];
|
|
41
|
+
}
|
|
42
|
+
export function membershipNote(catalog, m) {
|
|
43
|
+
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
44
|
+
return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${m.name}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
|
|
45
|
+
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
|
+
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
3
4
|
import { roundCents } from "./text.js";
|
|
4
5
|
import { cardValuation } from "./valuation.js";
|
|
6
|
+
function ineligibleText(catalog, wallet, notWith) {
|
|
7
|
+
const held = wallet.cards
|
|
8
|
+
.filter((c) => notWith.includes(c.id))
|
|
9
|
+
.map((c) => c.checkout_label ??
|
|
10
|
+
catalog.cards.find((k) => k.id === c.id)?.name ??
|
|
11
|
+
c.id);
|
|
12
|
+
return held.length === 0
|
|
13
|
+
? null
|
|
14
|
+
: `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
|
|
15
|
+
}
|
|
5
16
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
6
17
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
18
|
+
const paid = paidMemberships(catalog, {
|
|
19
|
+
memberships: wallet.memberships,
|
|
20
|
+
heldCards: [...held],
|
|
21
|
+
});
|
|
7
22
|
const offers = [];
|
|
8
23
|
const withoutOffer = [];
|
|
9
24
|
for (const card of catalog.cards) {
|
|
@@ -22,6 +37,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
22
37
|
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
23
38
|
0;
|
|
24
39
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
40
|
+
const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
|
|
25
41
|
offers.push({
|
|
26
42
|
cardId: card.id,
|
|
27
43
|
cardName: card.name,
|
|
@@ -39,13 +55,17 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
39
55
|
annualFee: card.annual_fee ?? null,
|
|
40
56
|
firstYearFee,
|
|
41
57
|
bonusValue: roundCents(value),
|
|
42
|
-
|
|
58
|
+
membershipFee,
|
|
59
|
+
ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
|
|
60
|
+
firstYearValue: roundCents(value - (firstYearFee ?? 0) - membershipFee),
|
|
43
61
|
held: held.has(card.id),
|
|
44
62
|
lastVerified: card.last_verified,
|
|
45
63
|
verifiedBy: card.verified_by,
|
|
46
64
|
});
|
|
47
65
|
}
|
|
48
|
-
offers.sort((a, b) =>
|
|
66
|
+
offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
|
|
67
|
+
b.firstYearValue - a.firstYearValue ||
|
|
68
|
+
a.cardId.localeCompare(b.cardId));
|
|
49
69
|
const notes = [
|
|
50
70
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
51
71
|
"Each offer is the public one on the issuer's page on the date shown, checked weekly. Yours may differ: issuers test and target offers, so use the one shown to you when you apply. Issuers also limit who can get a bonus; each card's note says how.",
|
package/dist/engine/recommend.js
CHANGED
|
@@ -3,17 +3,15 @@ import { bonusStatus } from "./bonus.js";
|
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
|
|
6
|
+
import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, shortDate, } from "./text.js";
|
|
7
7
|
import { cardValuation } from "./valuation.js";
|
|
8
|
+
// An on-pace welcome bonus card is mentioned when it's this close to the
|
|
9
|
+
// best card: within a point, and at least three quarters of its rate.
|
|
10
|
+
const CLOSE_PCT = 1;
|
|
11
|
+
const CLOSE_SHARE = 0.75;
|
|
8
12
|
const EPSILON = 1e-9;
|
|
9
13
|
export const STALE_AFTER_DAYS = 180;
|
|
10
14
|
const NEAR_CAP_SHARE = 0.2;
|
|
11
|
-
const NETWORK_NAMES = {
|
|
12
|
-
visa: 'Visa',
|
|
13
|
-
mastercard: 'Mastercard',
|
|
14
|
-
amex: 'American Express',
|
|
15
|
-
discover: 'Discover',
|
|
16
|
-
};
|
|
17
15
|
const PERIOD_WORDS = {
|
|
18
16
|
month: 'monthly',
|
|
19
17
|
quarter: 'quarterly',
|
|
@@ -23,6 +21,11 @@ const PERIOD_WORDS = {
|
|
|
23
21
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
24
22
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
25
23
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
24
|
+
// At exactly the break-even value the two cards tie, so only an exact
|
|
25
|
+
// threshold reads "more than".
|
|
26
|
+
const worthAbove = (n) => Math.abs(n * 100 - Math.round(n * 100)) < EPSILON
|
|
27
|
+
? `more than ${num(n)}¢`
|
|
28
|
+
: `${roundUp(n)}¢ or more`;
|
|
26
29
|
const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
|
|
27
30
|
function compareEntries(a, b) {
|
|
28
31
|
const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
|
|
@@ -149,7 +152,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
149
152
|
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.`);
|
|
150
153
|
}
|
|
151
154
|
for (const rule of evaluation.needsActivation) {
|
|
152
|
-
warnings.push(`Activate
|
|
155
|
+
warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
153
156
|
}
|
|
154
157
|
return warnings;
|
|
155
158
|
}
|
|
@@ -218,7 +221,7 @@ function sensitivity(catalog, entries) {
|
|
|
218
221
|
if (o.range && oRate > 0) {
|
|
219
222
|
const threshold = winner.effectiveReturnPct / oRate;
|
|
220
223
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
221
|
-
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${
|
|
224
|
+
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
222
225
|
}
|
|
223
226
|
}
|
|
224
227
|
}
|
|
@@ -259,7 +262,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
259
262
|
const warnings = [];
|
|
260
263
|
const notes = [];
|
|
261
264
|
if (merchant.source === 'none') {
|
|
262
|
-
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply.
|
|
265
|
+
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.`);
|
|
263
266
|
}
|
|
264
267
|
if (merchant.source === 'caller') {
|
|
265
268
|
warnings.push(`"${merchant.input}" isn't in earnrate's merchant list, so it uses the category given with the request. Check how it codes on your statement.`);
|
|
@@ -274,15 +277,38 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
274
277
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
275
278
|
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
276
279
|
for (const category of merchant.ambiguity.mayCodeAs) {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
if (alt
|
|
280
|
-
|
|
281
|
-
}
|
|
280
|
+
const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
|
|
281
|
+
const alt = alts[0];
|
|
282
|
+
if (!alt || !winner)
|
|
283
|
+
continue;
|
|
284
|
+
const as = `If it codes as ${categoryPhrase(catalog, category)}`;
|
|
285
|
+
// The same pick there may share the top with another card.
|
|
286
|
+
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
287
|
+
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
288
|
+
if (alt.card.id !== winner.card.id)
|
|
289
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
|
|
290
|
+
else if (twin)
|
|
291
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
|
|
282
292
|
}
|
|
283
293
|
}
|
|
284
294
|
if (!winner) {
|
|
285
295
|
warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
|
|
296
|
+
// Only where the merchant's data says what its website takes: without
|
|
297
|
+
// that, every network is assumed, and a gas pump has no website.
|
|
298
|
+
const record = [
|
|
299
|
+
...catalog.merchants.overrides,
|
|
300
|
+
...catalog.merchants.seed,
|
|
301
|
+
].find((m) => m.id === merchant.merchantId);
|
|
302
|
+
const online = merchant.channel === 'in_store' &&
|
|
303
|
+
merchant.merchantId !== null &&
|
|
304
|
+
record?.online?.accepts !== undefined
|
|
305
|
+
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
306
|
+
: null;
|
|
307
|
+
const alt = online
|
|
308
|
+
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
309
|
+
: undefined;
|
|
310
|
+
if (alt)
|
|
311
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
|
|
286
312
|
}
|
|
287
313
|
else if (merchant.accepts === null &&
|
|
288
314
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -300,9 +326,6 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
300
326
|
warnings.push(`${name}'s 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.`);
|
|
301
327
|
}
|
|
302
328
|
}
|
|
303
|
-
const tie = tieNote(entries);
|
|
304
|
-
if (tie)
|
|
305
|
-
notes.push(tie);
|
|
306
329
|
for (const id of merchant.bookedVia) {
|
|
307
330
|
const via = resolveMerchantById(catalog, id, purchase.channel);
|
|
308
331
|
if (!via || !winner)
|
|
@@ -313,19 +336,40 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
313
336
|
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
|
|
314
337
|
}
|
|
315
338
|
}
|
|
316
|
-
const
|
|
339
|
+
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
340
|
+
// Behind pace, the bonus card takes the pick; on pace, only where it earns
|
|
341
|
+
// as much as the best card (it costs nothing there). Within a point, it's
|
|
342
|
+
// mentioned; otherwise the bonus isn't this purchase's business.
|
|
343
|
+
const gap = open && winner
|
|
344
|
+
? winner.effectiveReturnPct - open.entry.effectiveReturnPct
|
|
345
|
+
: Infinity;
|
|
346
|
+
// Already the best card, it's the pick anyway: a tie needs no note.
|
|
347
|
+
const isBest = open !== null && open.entry === winner;
|
|
348
|
+
const ties = !isBest && gap <= EPSILON;
|
|
349
|
+
const bonus = open && (open.behind || ties) ? open : null;
|
|
350
|
+
const best = winner?.effectiveReturnPct ?? 0;
|
|
351
|
+
if (open &&
|
|
352
|
+
!bonus &&
|
|
353
|
+
gap <= CLOSE_PCT &&
|
|
354
|
+
open.entry.effectiveReturnPct >= best * CLOSE_SHARE) {
|
|
355
|
+
notes.push(`${displayName(open.entry)} earns ${num(open.entry.effectiveReturnPct)}% here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${num(best)}%, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
|
|
356
|
+
}
|
|
317
357
|
const chosen = bonus?.entry ?? winner;
|
|
358
|
+
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
359
|
+
const tie = bonus ? null : tieNote(entries);
|
|
360
|
+
if (tie)
|
|
361
|
+
notes.push(tie);
|
|
318
362
|
const top = ranked.find((r) => r.cardId === chosen?.card.id);
|
|
319
|
-
const
|
|
363
|
+
const first = ranked[0];
|
|
320
364
|
let bonusResult = null;
|
|
321
|
-
if (bonus && top &&
|
|
365
|
+
if (bonus && top && first) {
|
|
322
366
|
const { status } = bonus;
|
|
323
367
|
const remaining = status.remaining ?? 0;
|
|
324
368
|
const deadline = status.deadline ?? purchase.date;
|
|
325
369
|
const daysLeft = status.daysLeft ?? 0;
|
|
326
|
-
const costHere =
|
|
370
|
+
const costHere = ties || top.earned === null || first.earned === null
|
|
327
371
|
? null
|
|
328
|
-
: Math.round((
|
|
372
|
+
: Math.round((first.earned - top.earned) * 100) / 100;
|
|
329
373
|
bonusResult = {
|
|
330
374
|
cardId: top.cardId,
|
|
331
375
|
checkoutLabel: top.checkoutLabel,
|
|
@@ -341,11 +385,13 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
341
385
|
const worth = status.value === null
|
|
342
386
|
? ''
|
|
343
387
|
: `, worth about ${money(Math.round(status.value))}`;
|
|
344
|
-
notes.unshift(
|
|
345
|
-
? '
|
|
346
|
-
:
|
|
347
|
-
?
|
|
348
|
-
:
|
|
388
|
+
notes.unshift(ties
|
|
389
|
+
? `${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}.`
|
|
390
|
+
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
391
|
+
? ''
|
|
392
|
+
: costHere === null
|
|
393
|
+
? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
|
394
|
+
: ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
|
|
349
395
|
}
|
|
350
396
|
let confidence = merchant.confidence;
|
|
351
397
|
if (confidence === 'high' && chosen && top) {
|
|
@@ -373,17 +419,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
373
419
|
bonus: bonusResult,
|
|
374
420
|
};
|
|
375
421
|
}
|
|
422
|
+
// Behind a steady pace: less spent than an even share of the spending needed
|
|
423
|
+
// for the days gone by. Ahead of it, the bonus will likely be met anyway, so
|
|
424
|
+
// it doesn't justify a card that earns less.
|
|
425
|
+
function behindPace(status, date) {
|
|
426
|
+
if (status.spend === null || status.deadline === null)
|
|
427
|
+
return false;
|
|
428
|
+
const window = Math.max(1, daysBetween(status.opened, status.deadline));
|
|
429
|
+
const elapsed = Math.min(window, daysBetween(status.opened, date));
|
|
430
|
+
return status.spent < (status.spend * elapsed) / window;
|
|
431
|
+
}
|
|
376
432
|
// The open welcome bonus that most needs this purchase: the one with the most
|
|
377
|
-
// spend still needed per day left.
|
|
433
|
+
// spend still needed per day left. Only a bonus behind pace takes the pick
|
|
434
|
+
// (`behind`); one on pace is only mentioned.
|
|
378
435
|
function bonusPick(catalog, wallet, entries, date) {
|
|
379
436
|
let pick = null;
|
|
380
437
|
for (const entry of entries) {
|
|
381
438
|
const status = bonusStatus(catalog, wallet, entry.held, date);
|
|
382
439
|
if (status?.state !== 'open' || date < status.opened)
|
|
383
440
|
continue;
|
|
441
|
+
const behind = behindPace(status, date);
|
|
384
442
|
const pace = (status.remaining ?? 0) / Math.max(1, status.daysLeft ?? 1);
|
|
385
|
-
if (!pick ||
|
|
386
|
-
|
|
443
|
+
if (!pick ||
|
|
444
|
+
(behind && !pick.behind) ||
|
|
445
|
+
(behind === pick.behind && pace > pick.pace))
|
|
446
|
+
pick = { entry, status, behind, pace };
|
|
387
447
|
}
|
|
388
448
|
return pick;
|
|
389
449
|
}
|
package/dist/engine/setup.js
CHANGED
|
@@ -45,11 +45,11 @@ function matcher(card) {
|
|
|
45
45
|
(token.length >= 5 &&
|
|
46
46
|
haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
|
|
47
47
|
}
|
|
48
|
-
function needsAnniversary(card) {
|
|
48
|
+
export function needsAnniversary(card) {
|
|
49
49
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
50
50
|
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
|
|
51
51
|
}
|
|
52
|
-
function activationRules(card, today) {
|
|
52
|
+
export function activationRules(card, today) {
|
|
53
53
|
return card.rules
|
|
54
54
|
.filter((r) => r.requires?.activation === true &&
|
|
55
55
|
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
@@ -57,13 +57,24 @@ function activationRules(card, today) {
|
|
|
57
57
|
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
58
58
|
.map((r) => r.id);
|
|
59
59
|
}
|
|
60
|
+
// Bank of America Preferred Rewards tiers: a person holds one at a time, so
|
|
61
|
+
// with one saved the others aren't missing.
|
|
62
|
+
const TIER = /^bofa-rewards-/;
|
|
60
63
|
export function membershipsUsed(card) {
|
|
61
64
|
return [
|
|
62
65
|
...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
|
|
63
66
|
];
|
|
64
67
|
}
|
|
68
|
+
// Memberships that matter to a card: ones its rates need, and one the card
|
|
69
|
+
// itself needs (a Costco membership for Costco Anywhere).
|
|
70
|
+
export function membershipsFor(card) {
|
|
71
|
+
return [
|
|
72
|
+
...membershipsUsed(card),
|
|
73
|
+
...(card.requires_membership ? [card.requires_membership.id] : []),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
65
76
|
export function knownMemberships(catalog) {
|
|
66
|
-
return [...new Set(catalog.cards.flatMap(
|
|
77
|
+
return [...new Set(catalog.cards.flatMap(membershipsFor))].sort();
|
|
67
78
|
}
|
|
68
79
|
export function cardInfo(card, wallet, today) {
|
|
69
80
|
return {
|
|
@@ -84,6 +95,13 @@ export function cardInfo(card, wallet, today) {
|
|
|
84
95
|
default: c.default ?? null,
|
|
85
96
|
})),
|
|
86
97
|
memberships: membershipsUsed(card),
|
|
98
|
+
requiresMembership: card.requires_membership
|
|
99
|
+
? {
|
|
100
|
+
id: card.requires_membership.id,
|
|
101
|
+
name: card.requires_membership.name,
|
|
102
|
+
annualFee: card.requires_membership.annual_fee,
|
|
103
|
+
}
|
|
104
|
+
: null,
|
|
87
105
|
activation: activationRules(card, today),
|
|
88
106
|
needsAnniversary: needsAnniversary(card),
|
|
89
107
|
};
|
|
@@ -137,7 +155,8 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
137
155
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
138
156
|
});
|
|
139
157
|
}
|
|
140
|
-
const
|
|
158
|
+
const hasTier = wallet.memberships.some((m) => TIER.test(m));
|
|
159
|
+
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m) && !(hasTier && TIER.test(m)));
|
|
141
160
|
if (missing.length > 0) {
|
|
142
161
|
notes.push({
|
|
143
162
|
kind: 'membership',
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
3
|
import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
|
|
4
|
+
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
4
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
6
7
|
import { rotationGap } from "./rotation.js";
|
|
@@ -44,7 +45,10 @@ function withCurrentPicks(card, held, date) {
|
|
|
44
45
|
// Spends `lines` every month for a year. Each dollar goes to the card that
|
|
45
46
|
// earns the most on it right then, so spend moves to another card once a
|
|
46
47
|
// cap runs out.
|
|
47
|
-
export function simulateWallet(catalog, wallet, lines, start
|
|
48
|
+
export function simulateWallet(catalog, wallet, lines, start,
|
|
49
|
+
// Cards the person already has: their first year is behind them and any
|
|
50
|
+
// membership they need is paid for.
|
|
51
|
+
held = []) {
|
|
48
52
|
const cards = wallet.cards.map((held) => {
|
|
49
53
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
50
54
|
if (!card)
|
|
@@ -120,7 +124,16 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
120
124
|
});
|
|
121
125
|
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
|
|
122
126
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
127
|
+
const paid = paidMemberships(catalog, {
|
|
128
|
+
memberships: wallet.memberships,
|
|
129
|
+
heldCards: held,
|
|
130
|
+
merchantIds: lines.map((l) => l.merchant.merchantId),
|
|
131
|
+
});
|
|
132
|
+
const memberships = membershipCosts(cards.map((c) => c.card), paid);
|
|
133
|
+
const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
|
|
123
134
|
const notes = [];
|
|
135
|
+
for (const m of memberships)
|
|
136
|
+
notes.push(membershipNote(catalog, m));
|
|
124
137
|
const dates = monthDates(start);
|
|
125
138
|
for (const { card } of cards) {
|
|
126
139
|
const gaps = [
|
|
@@ -130,7 +143,8 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
130
143
|
notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
131
144
|
if (card.annual_fee === undefined)
|
|
132
145
|
notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
|
|
133
|
-
else if (card.
|
|
146
|
+
else if (!held.includes(card.id) &&
|
|
147
|
+
card.first_year_fee !== undefined &&
|
|
134
148
|
card.first_year_fee !== card.annual_fee)
|
|
135
149
|
notes.push(`${card.name}'s fee is $${String(card.first_year_fee)} the first year; the yearly figure uses the $${String(card.annual_fee)} that follows.`);
|
|
136
150
|
}
|
|
@@ -142,7 +156,9 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
142
156
|
spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
|
|
143
157
|
rewards,
|
|
144
158
|
annualFees,
|
|
145
|
-
|
|
159
|
+
memberships,
|
|
160
|
+
membershipFees,
|
|
161
|
+
net: roundCents(rewards - annualFees - membershipFees),
|
|
146
162
|
lines: lineResults,
|
|
147
163
|
notes,
|
|
148
164
|
};
|
|
@@ -192,7 +208,7 @@ function walletWith(base, card, picks, options) {
|
|
|
192
208
|
function bestSimulation(catalog, base, card, lines, options) {
|
|
193
209
|
let best = null;
|
|
194
210
|
for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
|
|
195
|
-
const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
|
|
211
|
+
const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start, options.held);
|
|
196
212
|
if (!best || sim.net > best.net + EPSILON)
|
|
197
213
|
best = sim;
|
|
198
214
|
}
|
|
@@ -200,7 +216,8 @@ function bestSimulation(catalog, base, card, lines, options) {
|
|
|
200
216
|
throw new Error(`no simulation for ${card.id}`);
|
|
201
217
|
return best;
|
|
202
218
|
}
|
|
203
|
-
const
|
|
219
|
+
const costs = (s) => s.annualFees + s.membershipFees;
|
|
220
|
+
const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
|
|
204
221
|
// Pairs that tie and share their first card differ only in an
|
|
205
222
|
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
206
223
|
function foldTies(pairs) {
|
|
@@ -208,7 +225,7 @@ function foldTies(pairs) {
|
|
|
208
225
|
for (const pair of pairs) {
|
|
209
226
|
const [a, b] = pair.cards;
|
|
210
227
|
const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
|
|
211
|
-
f
|
|
228
|
+
costs(f) === costs(pair) &&
|
|
212
229
|
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
213
230
|
if (!twin) {
|
|
214
231
|
folded.push({ ...pair, alternatives: [] });
|
|
@@ -275,8 +292,9 @@ function projectedWallet(wallet, catalog, start) {
|
|
|
275
292
|
}
|
|
276
293
|
export function simulateAdditions(catalog, wallet, lines, options) {
|
|
277
294
|
const projected = projectedWallet(wallet, catalog, options.start);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
295
|
+
const heldIds = wallet.cards.map((c) => c.id);
|
|
296
|
+
const current = simulateWallet(catalog, projected, lines, options.start, heldIds);
|
|
297
|
+
const held = new Set(heldIds);
|
|
280
298
|
const additions = [];
|
|
281
299
|
for (const card of catalog.cards) {
|
|
282
300
|
if (held.has(card.id) || !matchesFilters(card, options.filters))
|
|
@@ -285,19 +303,23 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
285
303
|
...options,
|
|
286
304
|
memberships: wallet.memberships,
|
|
287
305
|
valuations: wallet.valuations,
|
|
306
|
+
held: heldIds,
|
|
288
307
|
});
|
|
289
308
|
const extraRewards = roundCents(simulation.rewards - current.rewards);
|
|
290
309
|
const annualFee = card.annual_fee ?? 0;
|
|
310
|
+
const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
|
|
291
311
|
additions.push({
|
|
292
312
|
cardId: card.id,
|
|
293
313
|
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
294
314
|
extraRewards,
|
|
295
315
|
annualFee,
|
|
296
|
-
|
|
316
|
+
membershipFee,
|
|
317
|
+
gain: roundCents(extraRewards - annualFee - membershipFee),
|
|
297
318
|
simulation,
|
|
298
319
|
});
|
|
299
320
|
}
|
|
300
|
-
additions.sort((a, b) => b.gain - a.gain ||
|
|
321
|
+
additions.sort((a, b) => b.gain - a.gain ||
|
|
322
|
+
a.annualFee + a.membershipFee - (b.annualFee + b.membershipFee));
|
|
301
323
|
return { current, additions };
|
|
302
324
|
}
|
|
303
325
|
// Rough monthly amounts for a U.S. household: the starting point when someone
|