earnrate 0.2.4 → 0.2.6
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 +18 -15
- package/data/CHANGELOG.md +79 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +23 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +50 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
- package/data/cards/chase-ihg-one-rewards.yaml +15 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +17 -4
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
- package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/issuers.yaml +32 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +11 -6
- package/dist/cli/explore.js +17 -23
- package/dist/cli/import.js +10 -10
- package/dist/cli/offers.js +19 -8
- package/dist/cli/run.js +31 -20
- package/dist/cli/wallet.js +50 -5
- package/dist/data/file-store.js +9 -7
- package/dist/data/integrity.js +22 -2
- package/dist/data/load.js +13 -2
- package/dist/data/store.js +13 -22
- package/dist/data/wallet-edit.js +108 -8
- package/dist/engine/cheat-sheet.js +15 -19
- package/dist/engine/compare.js +10 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +13 -56
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +18 -5
- package/dist/engine/periods.js +18 -0
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +23 -37
- package/dist/engine/setup.js +28 -40
- package/dist/engine/simulate.js +11 -10
- package/dist/engine/text.js +49 -23
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +26 -11
- package/dist/engine/wallet-summary.js +85 -31
- package/dist/format/card-text.js +19 -36
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +15 -16
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +6 -6
- package/dist/format/wallet.js +5 -2
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +61 -13
- package/dist/mcp/inputs.js +16 -7
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/server.js +8 -1
- package/dist/mcp/tools/account.js +45 -17
- package/dist/mcp/tools/answers.js +84 -29
- package/dist/mcp/tools/wallet.js +109 -22
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +8 -12
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/engine/explore.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { membershipNeeded, paidMemberships } from "./memberships.js";
|
|
3
|
+
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { recommend } from "./recommend.js";
|
|
6
6
|
import { rotationGap } from "./rotation.js";
|
|
7
7
|
import { membershipsUsed } from "./setup.js";
|
|
8
|
-
import {
|
|
8
|
+
import { optionName } from "./wallet-summary.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
9
10
|
import { cardValuation } from "./valuation.js";
|
|
10
|
-
|
|
11
|
+
import { EPSILON } from "./compare.js";
|
|
11
12
|
export const ALL_PERSONAL_CARDS = {
|
|
12
13
|
kind: 'any',
|
|
13
14
|
business: false,
|
|
@@ -48,50 +49,6 @@ export function idealHolding(card, date, picks = {}) {
|
|
|
48
49
|
: {}),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
|
-
const PICK_PERIODS = {
|
|
52
|
-
month: 'set each month',
|
|
53
|
-
quarter: 'picked each quarter',
|
|
54
|
-
calendar_year: 'picked each year',
|
|
55
|
-
until_changed: 'until you change it',
|
|
56
|
-
};
|
|
57
|
-
const CAP_PERIODS = {
|
|
58
|
-
month: 'a month',
|
|
59
|
-
quarter: 'a quarter',
|
|
60
|
-
calendar_year: 'a calendar year',
|
|
61
|
-
cardmember_year: 'a cardmember year',
|
|
62
|
-
};
|
|
63
|
-
const plainWords = (s) => s
|
|
64
|
-
.toLowerCase()
|
|
65
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
66
|
-
.trim();
|
|
67
|
-
// A category from everyday words: an alias ("groceries") first, then the
|
|
68
|
-
// name, then the id, so "grocery" means supermarkets rather than the parent.
|
|
69
|
-
export function findCategory(catalog, text) {
|
|
70
|
-
const wanted = plainWords(text);
|
|
71
|
-
if (wanted === '')
|
|
72
|
-
return null;
|
|
73
|
-
const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
|
|
74
|
-
return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
|
|
75
|
-
}
|
|
76
|
-
export function categoryResolution(catalog, category, channel) {
|
|
77
|
-
const found = catalog.categories.find((c) => c.id === category);
|
|
78
|
-
if (!found)
|
|
79
|
-
throw new Error(`unknown category '${category}'`);
|
|
80
|
-
return {
|
|
81
|
-
input: found.name,
|
|
82
|
-
merchantId: null,
|
|
83
|
-
resolvedName: found.name,
|
|
84
|
-
category,
|
|
85
|
-
likelyMcc: null,
|
|
86
|
-
accepts: null,
|
|
87
|
-
ambiguity: null,
|
|
88
|
-
channel,
|
|
89
|
-
confidence: 'high',
|
|
90
|
-
source: 'category',
|
|
91
|
-
bookedVia: [],
|
|
92
|
-
inference: null,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
52
|
function walletOf(held, options) {
|
|
96
53
|
return {
|
|
97
54
|
valuations: options.valuations,
|
|
@@ -148,17 +105,17 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
148
105
|
: null,
|
|
149
106
|
};
|
|
150
107
|
}
|
|
151
|
-
function conditionsText(catalog, r) {
|
|
108
|
+
function conditionsText(catalog, card, r) {
|
|
152
109
|
const conditions = [];
|
|
153
110
|
if (r.pick) {
|
|
154
|
-
conditions.push(`Needs ${r.pick.options.map(
|
|
111
|
+
conditions.push(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
|
|
155
112
|
}
|
|
156
113
|
if (r.activation !== null)
|
|
157
114
|
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
158
115
|
if (r.membership !== null)
|
|
159
|
-
conditions.push(`
|
|
116
|
+
conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
|
|
160
117
|
if (r.cap) {
|
|
161
|
-
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]
|
|
118
|
+
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)}.`);
|
|
162
119
|
}
|
|
163
120
|
return conditions;
|
|
164
121
|
}
|
|
@@ -196,7 +153,7 @@ function compareExplored(a, b) {
|
|
|
196
153
|
export function exploreCards(catalog, query, options, resolved) {
|
|
197
154
|
const merchant = resolved ??
|
|
198
155
|
(query.merchant !== null
|
|
199
|
-
?
|
|
156
|
+
? resolvePlace(catalog, query.merchant, query.channel)
|
|
200
157
|
: categoryResolution(catalog, query.category ?? 'other', query.channel));
|
|
201
158
|
const purchase = {
|
|
202
159
|
merchant: merchant.input,
|
|
@@ -243,8 +200,8 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
243
200
|
picks,
|
|
244
201
|
requirements,
|
|
245
202
|
conditions: [
|
|
246
|
-
...conditionsText(catalog, requirements),
|
|
247
|
-
...membershipNeeded(card, paid),
|
|
203
|
+
...conditionsText(catalog, card, requirements),
|
|
204
|
+
...membershipNeeded(catalog, card, paid),
|
|
248
205
|
],
|
|
249
206
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
250
207
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
@@ -270,7 +227,7 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
|
270
227
|
assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
|
|
271
228
|
assumptions.push(options.memberships.length === 0
|
|
272
229
|
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
273
|
-
: `Assumes these memberships: ${options.memberships.
|
|
230
|
+
: `Assumes these memberships: ${listed(options.memberships.map((m) => membershipName(catalog, m)))}.`);
|
|
274
231
|
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
275
232
|
if (values !== null)
|
|
276
233
|
assumptions.push(values);
|
|
@@ -1,30 +1,48 @@
|
|
|
1
|
-
import { money } from "./text.js";
|
|
1
|
+
import { money, words } from "./text.js";
|
|
2
|
+
export function membership(catalog, id) {
|
|
3
|
+
return catalog.memberships.find((m) => m.id === id);
|
|
4
|
+
}
|
|
5
|
+
export function membershipName(catalog, id) {
|
|
6
|
+
return membership(catalog, id)?.name ?? words(id);
|
|
7
|
+
}
|
|
8
|
+
// "a Costco membership", for a sentence.
|
|
9
|
+
function withArticle(name) {
|
|
10
|
+
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
|
+
}
|
|
12
|
+
// Tiers of one membership: holding one, the others in its group aren't
|
|
13
|
+
// missing (BofA Preferred Rewards).
|
|
14
|
+
export function sameGroup(catalog, a, b) {
|
|
15
|
+
const group = membership(catalog, a)?.group;
|
|
16
|
+
return group !== undefined && group === membership(catalog, b)?.group;
|
|
17
|
+
}
|
|
2
18
|
// Memberships the person already pays for: the ones they've said, the ones a
|
|
3
19
|
// card they hold needs, and the ones their spending shows (only members shop
|
|
4
20
|
// at Costco).
|
|
5
21
|
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
6
22
|
const paid = new Set(memberships);
|
|
7
23
|
for (const card of catalog.cards) {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
if (heldCards.includes(card.id) ||
|
|
12
|
-
m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
13
|
-
paid.add(m.id);
|
|
24
|
+
const id = card.requires_membership;
|
|
25
|
+
if (id !== undefined && heldCards.includes(card.id))
|
|
26
|
+
paid.add(id);
|
|
14
27
|
}
|
|
28
|
+
for (const m of catalog.memberships)
|
|
29
|
+
if (m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
30
|
+
paid.add(m.id);
|
|
15
31
|
return paid;
|
|
16
32
|
}
|
|
17
33
|
// What these cards' memberships add, once each, leaving out the paid ones.
|
|
18
|
-
export function membershipCosts(cards, paid) {
|
|
34
|
+
export function membershipCosts(catalog, cards, paid) {
|
|
19
35
|
const costs = new Map();
|
|
20
36
|
for (const card of cards) {
|
|
21
|
-
const m = card.requires_membership
|
|
37
|
+
const m = card.requires_membership === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: membership(catalog, card.requires_membership);
|
|
22
40
|
if (!m || paid.has(m.id))
|
|
23
41
|
continue;
|
|
24
42
|
const cost = costs.get(m.id) ?? {
|
|
25
43
|
id: m.id,
|
|
26
44
|
name: m.name,
|
|
27
|
-
annualFee: m.annual_fee,
|
|
45
|
+
annualFee: m.annual_fee ?? 0,
|
|
28
46
|
cardIds: [],
|
|
29
47
|
};
|
|
30
48
|
cost.cardIds.push(card.id);
|
|
@@ -32,14 +50,22 @@ export function membershipCosts(cards, paid) {
|
|
|
32
50
|
}
|
|
33
51
|
return [...costs.values()];
|
|
34
52
|
}
|
|
53
|
+
// "a Costco membership ($65 a year)"
|
|
54
|
+
export function membershipText(catalog, id) {
|
|
55
|
+
const m = membership(catalog, id);
|
|
56
|
+
const name = withArticle(m?.name ?? words(id));
|
|
57
|
+
return m?.annual_fee === undefined
|
|
58
|
+
? name
|
|
59
|
+
: `${name} (${money(m.annual_fee)} a year)`;
|
|
60
|
+
}
|
|
35
61
|
// The card's membership as a condition, unless it's paid for.
|
|
36
|
-
export function membershipNeeded(card, paid) {
|
|
37
|
-
const
|
|
38
|
-
return
|
|
39
|
-
? [`Needs ${
|
|
62
|
+
export function membershipNeeded(catalog, card, paid) {
|
|
63
|
+
const id = card.requires_membership;
|
|
64
|
+
return id !== undefined && !paid.has(id)
|
|
65
|
+
? [`Needs ${membershipText(catalog, id)}.`]
|
|
40
66
|
: [];
|
|
41
67
|
}
|
|
42
68
|
export function membershipNote(catalog, m) {
|
|
43
69
|
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.`;
|
|
70
|
+
return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
|
|
45
71
|
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
|
-
import {
|
|
4
|
+
import { inEffect } from "./periods.js";
|
|
5
|
+
import { money, roundCents } from "./text.js";
|
|
5
6
|
import { cardValuation } from "./valuation.js";
|
|
6
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
7
8
|
const held = wallet.cards
|
|
@@ -13,6 +14,18 @@ function ineligibleText(catalog, wallet, notWith) {
|
|
|
13
14
|
? null
|
|
14
15
|
: `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
|
|
15
16
|
}
|
|
17
|
+
export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
|
|
18
|
+
// "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
|
|
19
|
+
// months": one wording for the site, the search pages and the CLI.
|
|
20
|
+
export function offerTerms(o, currencyName) {
|
|
21
|
+
const amount = o.points !== null && o.points !== undefined
|
|
22
|
+
? `${o.points.toLocaleString('en-US')} ${currencyName}`
|
|
23
|
+
: money(o.dollars ?? 0);
|
|
24
|
+
const spend = o.spend > 0
|
|
25
|
+
? `after ${money(o.spend)} in purchases within ${String(o.months)} month${o.months === 1 ? '' : 's'}`
|
|
26
|
+
: 'with no spending required';
|
|
27
|
+
return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
|
|
28
|
+
}
|
|
16
29
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
17
30
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
18
31
|
const paid = paidMemberships(catalog, {
|
|
@@ -29,7 +42,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
29
42
|
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
30
43
|
continue;
|
|
31
44
|
const offer = card.welcome_offer;
|
|
32
|
-
if (!offer || (offer
|
|
45
|
+
if (!offer || !inEffect(offer, today)) {
|
|
33
46
|
withoutOffer.push(card.id);
|
|
34
47
|
continue;
|
|
35
48
|
}
|
|
@@ -37,7 +50,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
37
50
|
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
38
51
|
0;
|
|
39
52
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
40
|
-
const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
|
|
53
|
+
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
41
54
|
offers.push({
|
|
42
55
|
cardId: card.id,
|
|
43
56
|
cardName: card.name,
|
|
@@ -68,8 +81,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
68
81
|
a.cardId.localeCompare(b.cardId));
|
|
69
82
|
const notes = [
|
|
70
83
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
71
|
-
|
|
72
|
-
"First-year value is the bonus at your point values minus the first year's annual fee. It leaves out what the card earns on your spending and any credits or perks.",
|
|
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
|
+
"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.",
|
|
73
86
|
];
|
|
74
87
|
return { offers, withoutOffer, notes };
|
|
75
88
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -38,3 +38,21 @@ export function choicePeriod(card) {
|
|
|
38
38
|
];
|
|
39
39
|
return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
|
|
40
40
|
}
|
|
41
|
+
// Whether a dated rule or offer is in effect on `date`; an open end has no
|
|
42
|
+
// limit on that side.
|
|
43
|
+
export const inEffect = (window, date) => (window.valid_from === undefined || window.valid_from <= date) &&
|
|
44
|
+
(window.valid_to === undefined || window.valid_to >= date);
|
|
45
|
+
// The last day of a period key ("2026-Q4", "2026-10", "2026").
|
|
46
|
+
export function periodEnd(period) {
|
|
47
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
48
|
+
if (quarter) {
|
|
49
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
50
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
51
|
+
}
|
|
52
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
53
|
+
if (month) {
|
|
54
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
55
|
+
return last.toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
58
|
+
}
|
package/dist/engine/profiles.js
CHANGED
|
@@ -2,6 +2,14 @@ export const VALUATION_PROFILES = {
|
|
|
2
2
|
flat: { MR: 1.0, UR: 1.0 },
|
|
3
3
|
'points-heavy': { MR: 1.7, UR: 1.7 },
|
|
4
4
|
};
|
|
5
|
+
// "MR and UR at 1.7¢": what a profile sets, for help text.
|
|
6
|
+
export function profileText(profile) {
|
|
7
|
+
const values = Object.entries(VALUATION_PROFILES[profile]);
|
|
8
|
+
const cents = [...new Set(values.map(([, v]) => v))];
|
|
9
|
+
return cents.length === 1
|
|
10
|
+
? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
|
|
11
|
+
: values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
|
|
12
|
+
}
|
|
5
13
|
export function isValuationProfile(name) {
|
|
6
14
|
return Object.hasOwn(VALUATION_PROFILES, name);
|
|
7
15
|
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,23 +1,15 @@
|
|
|
1
|
-
import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
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 {
|
|
6
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
7
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
7
8
|
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;
|
|
12
|
-
const EPSILON = 1e-9;
|
|
13
9
|
export const STALE_AFTER_DAYS = 180;
|
|
10
|
+
// The warning when no card works; the CLI shows it as the answer instead.
|
|
11
|
+
export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
|
|
14
12
|
const NEAR_CAP_SHARE = 0.2;
|
|
15
|
-
const PERIOD_WORDS = {
|
|
16
|
-
month: 'monthly',
|
|
17
|
-
quarter: 'quarterly',
|
|
18
|
-
calendar_year: 'calendar-year',
|
|
19
|
-
cardmember_year: 'cardmember-year',
|
|
20
|
-
};
|
|
21
13
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
22
14
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
23
15
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
@@ -112,7 +104,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
112
104
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
113
105
|
}
|
|
114
106
|
if (currency !== 'cash') {
|
|
115
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${
|
|
107
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.effectiveReturnPct)} back.`);
|
|
116
108
|
}
|
|
117
109
|
return lines;
|
|
118
110
|
}
|
|
@@ -121,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
121
113
|
const { best, nominal } = evaluation;
|
|
122
114
|
const warnings = [];
|
|
123
115
|
const capLabel = (e) => e.cap
|
|
124
|
-
? `${money(e.cap.cap.amount)} ${
|
|
116
|
+
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
125
117
|
: '';
|
|
126
118
|
if (best.cap && best.rule) {
|
|
127
119
|
const { remaining, cap, period } = best.cap;
|
|
@@ -152,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
152
144
|
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.`);
|
|
153
145
|
}
|
|
154
146
|
for (const rule of evaluation.needsActivation) {
|
|
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)}`}.`);
|
|
147
|
+
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
156
148
|
}
|
|
157
149
|
return warnings;
|
|
158
150
|
}
|
|
@@ -191,7 +183,7 @@ function tieNote(entries) {
|
|
|
191
183
|
tied.some((e) => e.evaluation.best.cap !== null)
|
|
192
184
|
? 'because its rate has no cap'
|
|
193
185
|
: 'by a fixed order; either works';
|
|
194
|
-
return `${names} also ${verb} ${
|
|
186
|
+
return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
|
|
195
187
|
}
|
|
196
188
|
// Finds the closest point inside a currency's value range where the winner
|
|
197
189
|
// stops winning, varying one currency at a time. Returns are linear in the
|
|
@@ -286,19 +278,18 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
286
278
|
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
287
279
|
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
288
280
|
if (alt.card.id !== winner.card.id)
|
|
289
|
-
warnings.push(`${as}, ${displayName(alt)} wins (${
|
|
281
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
|
|
290
282
|
else if (twin)
|
|
291
|
-
warnings.push(`${as}, ${displayName(twin)} earns as much (${
|
|
283
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
|
|
292
284
|
}
|
|
293
285
|
}
|
|
294
286
|
if (!winner) {
|
|
295
|
-
warnings.push(
|
|
287
|
+
warnings.push(noCardText(merchant.resolvedName));
|
|
296
288
|
// Only where the merchant's data says what its website takes: without
|
|
297
289
|
// that, every network is assumed, and a gas pump has no website.
|
|
298
|
-
const record =
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
].find((m) => m.id === merchant.merchantId);
|
|
290
|
+
const record = merchant.merchantId === null
|
|
291
|
+
? undefined
|
|
292
|
+
: findMerchant(catalog, merchant.merchantId);
|
|
302
293
|
const online = merchant.channel === 'in_store' &&
|
|
303
294
|
merchant.merchantId !== null &&
|
|
304
295
|
record?.online?.accepts !== undefined
|
|
@@ -308,7 +299,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
308
299
|
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
309
300
|
: undefined;
|
|
310
301
|
if (alt)
|
|
311
|
-
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${
|
|
302
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
|
|
312
303
|
}
|
|
313
304
|
else if (merchant.accepts === null &&
|
|
314
305
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
|
@@ -333,7 +324,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
333
324
|
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
334
325
|
.entries[0];
|
|
335
326
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
336
|
-
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${
|
|
327
|
+
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
|
|
337
328
|
}
|
|
338
329
|
}
|
|
339
330
|
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
@@ -348,11 +339,8 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
348
339
|
const ties = !isBest && gap <= EPSILON;
|
|
349
340
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
350
341
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
351
|
-
if (open &&
|
|
352
|
-
|
|
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.`);
|
|
342
|
+
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
343
|
+
notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(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
344
|
}
|
|
357
345
|
const chosen = bonus?.entry ?? winner;
|
|
358
346
|
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
@@ -369,7 +357,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
369
357
|
const daysLeft = status.daysLeft ?? 0;
|
|
370
358
|
const costHere = ties || top.earned === null || first.earned === null
|
|
371
359
|
? null
|
|
372
|
-
:
|
|
360
|
+
: roundCents(first.earned - top.earned);
|
|
373
361
|
bonusResult = {
|
|
374
362
|
cardId: top.cardId,
|
|
375
363
|
checkoutLabel: top.checkoutLabel,
|
|
@@ -380,17 +368,15 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
380
368
|
costHere,
|
|
381
369
|
};
|
|
382
370
|
const pace = daysLeft >= 7
|
|
383
|
-
? ` (about ${
|
|
371
|
+
? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
|
|
384
372
|
: '';
|
|
385
|
-
const worth = status.value === null
|
|
386
|
-
? ''
|
|
387
|
-
: `, worth about ${money(Math.round(status.value))}`;
|
|
373
|
+
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
388
374
|
notes.unshift(ties
|
|
389
375
|
? `${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
376
|
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
391
377
|
? ''
|
|
392
378
|
: costHere === null
|
|
393
|
-
? ` It earns ${
|
|
379
|
+
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
|
394
380
|
: ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
|
|
395
381
|
}
|
|
396
382
|
let confidence = merchant.confidence;
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
2
3
|
import { daysBetween } from "./periods.js";
|
|
3
4
|
import { rotationGap } from "./rotation.js";
|
|
5
|
+
import { idList, listed } from "./text.js";
|
|
4
6
|
// Rules that start within this many days are worth activating now.
|
|
5
7
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
6
|
-
const
|
|
7
|
-
amex: 'american express',
|
|
8
|
-
'bank-of-america': 'bofa boa bankofamerica',
|
|
9
|
-
'capital-one': 'capitalone',
|
|
10
|
-
citi: 'citibank',
|
|
11
|
-
usbank: 'us bank',
|
|
12
|
-
'wells-fargo': 'wf wellsfargo',
|
|
13
|
-
'td-bank': 'td',
|
|
14
|
-
column: 'bilt',
|
|
15
|
-
};
|
|
16
|
-
const words = (s) => s
|
|
8
|
+
const searchWords = (s) => s
|
|
17
9
|
.toLowerCase()
|
|
18
10
|
.replace(/\+/g, ' plus ')
|
|
19
11
|
.replace(/&/g, ' and ')
|
|
@@ -32,12 +24,12 @@ function editDistance(a, b) {
|
|
|
32
24
|
}
|
|
33
25
|
return row[b.length] ?? 0;
|
|
34
26
|
}
|
|
35
|
-
function matcher(card) {
|
|
27
|
+
function matcher(catalog, card) {
|
|
36
28
|
const haystack = [
|
|
37
|
-
...
|
|
38
|
-
...
|
|
39
|
-
...
|
|
40
|
-
...
|
|
29
|
+
...searchWords(card.id),
|
|
30
|
+
...searchWords(card.name),
|
|
31
|
+
...searchWords(card.issuer),
|
|
32
|
+
...(catalog.issuers.find((i) => i.id === card.issuer)?.search ?? []).flatMap(searchWords),
|
|
41
33
|
];
|
|
42
34
|
const compact = haystack.join('');
|
|
43
35
|
return (token) => haystack.some((w) => w.startsWith(token)) ||
|
|
@@ -57,9 +49,6 @@ export function activationRules(card, today) {
|
|
|
57
49
|
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
58
50
|
.map((r) => r.id);
|
|
59
51
|
}
|
|
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-/;
|
|
63
52
|
export function membershipsUsed(card) {
|
|
64
53
|
return [
|
|
65
54
|
...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
|
|
@@ -70,13 +59,13 @@ export function membershipsUsed(card) {
|
|
|
70
59
|
export function membershipsFor(card) {
|
|
71
60
|
return [
|
|
72
61
|
...membershipsUsed(card),
|
|
73
|
-
...(card.requires_membership ? [card.requires_membership
|
|
62
|
+
...(card.requires_membership ? [card.requires_membership] : []),
|
|
74
63
|
];
|
|
75
64
|
}
|
|
76
65
|
export function knownMemberships(catalog) {
|
|
77
|
-
return
|
|
66
|
+
return catalog.memberships.map((m) => m.id).sort();
|
|
78
67
|
}
|
|
79
|
-
export function cardInfo(card, wallet, today) {
|
|
68
|
+
export function cardInfo(catalog, card, wallet, today) {
|
|
80
69
|
return {
|
|
81
70
|
id: card.id,
|
|
82
71
|
name: card.name,
|
|
@@ -95,13 +84,13 @@ export function cardInfo(card, wallet, today) {
|
|
|
95
84
|
default: c.default ?? null,
|
|
96
85
|
})),
|
|
97
86
|
memberships: membershipsUsed(card),
|
|
98
|
-
requiresMembership: card.requires_membership
|
|
99
|
-
?
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
87
|
+
requiresMembership: card.requires_membership === undefined
|
|
88
|
+
? null
|
|
89
|
+
: {
|
|
90
|
+
id: card.requires_membership,
|
|
91
|
+
name: membershipName(catalog, card.requires_membership),
|
|
92
|
+
annualFee: membership(catalog, card.requires_membership)?.annual_fee ?? 0,
|
|
93
|
+
},
|
|
105
94
|
activation: activationRules(card, today),
|
|
106
95
|
needsAnniversary: needsAnniversary(card),
|
|
107
96
|
};
|
|
@@ -110,12 +99,12 @@ export function cardInfo(card, wallet, today) {
|
|
|
110
99
|
// issuer (as a prefix, or with a typo or two in a long word). With no full
|
|
111
100
|
// match, the cards matching the most words come back instead.
|
|
112
101
|
export function searchCards(catalog, query, wallet, today) {
|
|
113
|
-
const tokens =
|
|
102
|
+
const tokens = searchWords(query);
|
|
114
103
|
const scored = catalog.cards.map((card) => {
|
|
115
|
-
const matches = matcher(card);
|
|
104
|
+
const matches = matcher(catalog, card);
|
|
116
105
|
return { card, score: tokens.filter((t) => matches(t)).length };
|
|
117
106
|
});
|
|
118
|
-
const byShortestId = (a, b) =>
|
|
107
|
+
const byShortestId = (a, b) => searchWords(a.card.id).length - searchWords(b.card.id).length ||
|
|
119
108
|
a.card.id.localeCompare(b.card.id);
|
|
120
109
|
const full = scored.filter((s) => s.score === tokens.length);
|
|
121
110
|
const best = Math.max(0, ...scored.map((s) => s.score));
|
|
@@ -129,11 +118,10 @@ export function searchCards(catalog, query, wallet, today) {
|
|
|
129
118
|
.sort(byShortestId)
|
|
130
119
|
.slice(0, 5);
|
|
131
120
|
return {
|
|
132
|
-
cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
|
|
121
|
+
cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
|
|
133
122
|
exact,
|
|
134
123
|
};
|
|
135
124
|
}
|
|
136
|
-
const list = (ids) => ids.join(', ');
|
|
137
125
|
// What a card in the wallet still needs before its answers are right.
|
|
138
126
|
export function setupNotes(catalog, wallet, held, today) {
|
|
139
127
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -147,27 +135,27 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
147
135
|
(staleChoice === null || choice.period === 'until_changed');
|
|
148
136
|
if (current)
|
|
149
137
|
continue;
|
|
150
|
-
const options =
|
|
138
|
+
const options = idList(Object.keys(choice.options));
|
|
151
139
|
notes.push({
|
|
152
140
|
kind: 'pick',
|
|
153
141
|
text: choice.default && picked.has(choice.id)
|
|
154
|
-
? `${name}'s ${choice.id} pick is assumed to be ${
|
|
142
|
+
? `${name}'s ${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}).`
|
|
155
143
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
156
144
|
});
|
|
157
145
|
}
|
|
158
|
-
|
|
159
|
-
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.
|
|
146
|
+
// With one tier of a group saved, the others aren't missing.
|
|
147
|
+
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
|
|
160
148
|
if (missing.length > 0) {
|
|
161
149
|
notes.push({
|
|
162
150
|
kind: 'membership',
|
|
163
|
-
text: `Some of ${name}'s rates need
|
|
151
|
+
text: `Some of ${name}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
164
152
|
});
|
|
165
153
|
}
|
|
166
154
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
167
155
|
if (toActivate.length > 0) {
|
|
168
156
|
notes.push({
|
|
169
157
|
kind: 'activation',
|
|
170
|
-
text: `${name} has rates that apply only once activated with the issuer: ${
|
|
158
|
+
text: `${name} has rates that apply only once activated with the issuer: ${idList(toActivate)}. Mark the ones already activated.`,
|
|
171
159
|
});
|
|
172
160
|
}
|
|
173
161
|
if (needsAnniversary(card) && held.anniversary === undefined) {
|