earnrate 0.2.16 → 0.2.18
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 +22 -10
- package/data/CHANGELOG.md +33 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +9 -12
- package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
- package/data/cards/chase-ihg-one-rewards.yaml +13 -2
- package/data/cards/chase-ink-business-cash.yaml +5 -1
- package/data/cards/chase-ink-business-unlimited.yaml +5 -1
- package/data/cards/chase-prime-visa.yaml +3 -3
- package/data/cards/chase-sapphire-reserve.yaml +12 -3
- package/data/cards/chase-united-business.yaml +8 -1
- package/data/cards/chase-united-club-business.yaml +10 -1
- package/data/cards/chase-united-club.yaml +5 -1
- package/data/cards/chase-united-explorer.yaml +5 -1
- package/data/cards/chase-united-quest.yaml +5 -1
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +16 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/changes.yaml +2840 -0
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +18 -3
- package/dist/cli/wallet.js +46 -11
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +20 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +21 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +42 -7
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +52 -15
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +60 -6
- package/dist/engine/periods.js +7 -0
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +21 -13
- package/dist/engine/setup.js +12 -8
- package/dist/engine/simulate.js +57 -17
- package/dist/engine/text.js +110 -5
- package/dist/engine/wallet-change.js +34 -15
- package/dist/engine/wallet-summary.js +29 -10
- package/dist/format/card-text.js +10 -11
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +8 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +12 -2
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +47 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +33 -9
- package/dist/schema/card.js +18 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +11 -3
- package/package.json +1 -1
package/dist/engine/offers.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
|
+
import { allCreditsYear } from "./credits.js";
|
|
2
3
|
import { matchesFilters } from "./explore.js";
|
|
4
|
+
import { simulateAdditions, typicalSpendLines } from "./simulate.js";
|
|
3
5
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
6
|
import { inEffect } from "./periods.js";
|
|
5
|
-
import {
|
|
7
|
+
import { CREDITS_APART, heldName, listed, money, roundCents } from "./text.js";
|
|
6
8
|
import { cardValuation } from "./valuation.js";
|
|
7
9
|
function ineligibleText(catalog, wallet, notWith) {
|
|
8
10
|
const held = wallet.cards
|
|
@@ -24,8 +26,27 @@ export function offerTerms(o, currencyName) {
|
|
|
24
26
|
: 'with no spending required';
|
|
25
27
|
return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
|
|
26
28
|
}
|
|
27
|
-
|
|
29
|
+
const WEEKS_A_MONTH = 52 / 12;
|
|
30
|
+
function minimumSpend(spend, months, monthly) {
|
|
31
|
+
if (spend <= 0)
|
|
32
|
+
return null;
|
|
33
|
+
const weeks = monthly > 0 ? Math.ceil(spend / (monthly / WEEKS_A_MONTH)) : Infinity;
|
|
34
|
+
return {
|
|
35
|
+
reachable: monthly * months >= spend,
|
|
36
|
+
weeks: Number.isFinite(weeks) ? weeks : 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function compareOffers(catalog, wallet, filters, today, spending = { lines: typicalSpendLines(catalog), typical: true }) {
|
|
28
40
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
41
|
+
const skipped = new Set(wallet.skipped_issuers ?? []);
|
|
42
|
+
const monthly = spending.lines.reduce((sum, l) => sum + l.monthly, 0);
|
|
43
|
+
const { additions } = simulateAdditions(catalog, wallet, spending.lines, {
|
|
44
|
+
filters: { ...filters, issuer: null, includeClosed: true },
|
|
45
|
+
memberships: wallet.memberships,
|
|
46
|
+
valuations: wallet.valuations,
|
|
47
|
+
start: today,
|
|
48
|
+
});
|
|
49
|
+
const added = new Map(additions.map((a) => [a.cardId, a]));
|
|
29
50
|
const paid = paidMemberships(catalog, {
|
|
30
51
|
memberships: wallet.memberships,
|
|
31
52
|
heldCards: [...held],
|
|
@@ -37,6 +58,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
37
58
|
continue;
|
|
38
59
|
if (!filters.includeHeld && held.has(card.id))
|
|
39
60
|
continue;
|
|
61
|
+
if (skipped.has(card.issuer))
|
|
62
|
+
continue;
|
|
40
63
|
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
41
64
|
continue;
|
|
42
65
|
const offer = card.welcome_offer;
|
|
@@ -49,6 +72,27 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
49
72
|
0;
|
|
50
73
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
51
74
|
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
75
|
+
const firstYearValue = roundCents(value - (firstYearFee ?? 0) - membershipFee);
|
|
76
|
+
const credits = allCreditsYear(catalog, card, today).yearly;
|
|
77
|
+
const addition = added.get(card.id);
|
|
78
|
+
const minimum = minimumSpend(offer.spend, offer.months, monthly);
|
|
79
|
+
const plan = addition
|
|
80
|
+
? (() => {
|
|
81
|
+
const reached = minimum === null || minimum.reachable;
|
|
82
|
+
const yearOne = roundCents(addition.extraRewards +
|
|
83
|
+
(reached ? value : 0) -
|
|
84
|
+
(firstYearFee ?? 0) -
|
|
85
|
+
addition.membershipFee);
|
|
86
|
+
const yearTwo = roundCents(addition.extraRewards - addition.annualFee - addition.membershipFee);
|
|
87
|
+
return {
|
|
88
|
+
extraRewards: addition.extraRewards,
|
|
89
|
+
minimumSpend: minimum,
|
|
90
|
+
yearOne,
|
|
91
|
+
yearTwo,
|
|
92
|
+
twoYears: roundCents(yearOne + yearTwo),
|
|
93
|
+
};
|
|
94
|
+
})()
|
|
95
|
+
: null;
|
|
52
96
|
offers.push({
|
|
53
97
|
cardId: card.id,
|
|
54
98
|
cardName: card.name,
|
|
@@ -68,20 +112,30 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
68
112
|
bonusValue: roundCents(value),
|
|
69
113
|
membershipFee,
|
|
70
114
|
ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
|
|
71
|
-
firstYearValue
|
|
115
|
+
firstYearValue,
|
|
116
|
+
credits,
|
|
117
|
+
firstYearValueIfCreditsUsed: roundCents(firstYearValue + credits),
|
|
118
|
+
plan,
|
|
72
119
|
held: held.has(card.id),
|
|
73
120
|
lastVerified: card.last_verified,
|
|
74
121
|
verifiedBy: card.verified_by,
|
|
75
122
|
});
|
|
76
123
|
}
|
|
77
124
|
offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
|
|
125
|
+
(b.plan?.twoYears ?? -Infinity) - (a.plan?.twoYears ?? -Infinity) ||
|
|
78
126
|
b.firstYearValue - a.firstYearValue ||
|
|
79
127
|
a.cardId.localeCompare(b.cardId));
|
|
80
128
|
const notes = [
|
|
81
129
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
82
130
|
`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.`,
|
|
83
|
-
|
|
84
|
-
|
|
131
|
+
`Ranked on the first two years together: what each card ${wallet.cards.length > 0 ? 'adds to your cards' : 'earns'} on ${spending.typical ? 'typical U.S. spending' : 'your spending'}${wallet.cards.length > 0 ? ' (it takes the purchases where it earns more)' : ''}, plus the welcome bonus in year one if the minimum spend fits in the window with all your spending on it, less each year's annual fee and any membership it needs. Year one counts the card at its standard rates.`,
|
|
132
|
+
"First-year value is the bonus alone at your point values, less the first year's fee and membership.",
|
|
133
|
+
CREDITS_APART,
|
|
134
|
+
...(skipped.size > 0
|
|
135
|
+
? [
|
|
136
|
+
`Leaves out cards from ${listed([...skipped].map((i) => catalog.issuers.find((x) => x.id === i)?.name ?? i))}, as you asked.`,
|
|
137
|
+
]
|
|
138
|
+
: []),
|
|
85
139
|
];
|
|
86
|
-
return { offers, withoutOffer, notes };
|
|
140
|
+
return { offers, withoutOffer, notes, typical: spending.typical };
|
|
87
141
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -20,6 +20,13 @@ export function periodKey(period, date, anniversary) {
|
|
|
20
20
|
return String(date.slice(5) >= anniversary ? year : year - 1);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
+
// MM-DD that is a real day of the year; 02-29 counts.
|
|
24
|
+
export function isAnniversary(text) {
|
|
25
|
+
if (!/^\d{2}-\d{2}$/.test(text))
|
|
26
|
+
return false;
|
|
27
|
+
const date = new Date(`2024-${text}T00:00:00Z`);
|
|
28
|
+
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
29
|
+
}
|
|
23
30
|
export function daysBetween(from, to) {
|
|
24
31
|
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
|
|
25
32
|
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
-
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
3
|
+
import { amountUpgrade, evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { introWindows, openedOn, withIntro } from "./intro.js";
|
|
5
5
|
import { membershipName } from "./memberships.js";
|
|
6
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
@@ -9,7 +9,7 @@ import { creditAt, creditDollars } from "./credits.js";
|
|
|
9
9
|
import { ruleWhat } from "./rule-text.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
12
|
-
import { CAP_ADJECTIVES, categoryPhrase,
|
|
12
|
+
import { amountUnknownText, CAP_ADJECTIVES, categoryPhrase, codesAs, creditAnswerText, currencyLabel, currencyName, earnText, heldName, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, possessive, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
13
13
|
import { cardValuation } from "./valuation.js";
|
|
14
14
|
export const STALE_AFTER_DAYS = 180;
|
|
15
15
|
// The engine only tells the U.S. from everywhere else.
|
|
@@ -99,6 +99,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
|
|
|
99
99
|
}
|
|
100
100
|
: null;
|
|
101
101
|
const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
|
|
102
|
+
const larger = amountUpgrade(card, held, ctx, evaluation.best.rate);
|
|
102
103
|
const credit = options.credits === false
|
|
103
104
|
? null
|
|
104
105
|
: creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
|
|
@@ -133,6 +134,15 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
|
|
|
133
134
|
creditPct,
|
|
134
135
|
}
|
|
135
136
|
: null,
|
|
137
|
+
ifLarger: larger
|
|
138
|
+
? {
|
|
139
|
+
amount: larger.amount,
|
|
140
|
+
rate: larger.nominalRate,
|
|
141
|
+
returnPct: (perDollar - evaluation.best.rate + larger.rate) *
|
|
142
|
+
valuation.centsPerUnit -
|
|
143
|
+
fee,
|
|
144
|
+
}
|
|
145
|
+
: null,
|
|
136
146
|
});
|
|
137
147
|
}
|
|
138
148
|
entries.sort(compareEntries);
|
|
@@ -375,6 +385,15 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
375
385
|
const currency = e.valuation.currency;
|
|
376
386
|
assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
|
|
377
387
|
}
|
|
388
|
+
for (const e of entries) {
|
|
389
|
+
if (!e.ifLarger || !winner)
|
|
390
|
+
continue;
|
|
391
|
+
if (e !== winner &&
|
|
392
|
+
e.ifLarger.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
393
|
+
continue;
|
|
394
|
+
const currency = e.valuation.currency;
|
|
395
|
+
assumptions.push(amountUnknownText(displayName(e), e.ifLarger.amount, earnText(catalog, e.ifLarger.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
|
|
396
|
+
}
|
|
378
397
|
for (const e of entries) {
|
|
379
398
|
if (!e.ifNew || !winner)
|
|
380
399
|
continue;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ALL_PERSONAL_CARDS, matchesFilters } from "./explore.js";
|
|
2
|
+
import { usedCreditsYear } from "./credits.js";
|
|
3
|
+
import { daysBetween, isAnniversary } from "./periods.js";
|
|
4
|
+
import { projectedWallet, simulateSingles, simulateWallet, typicalSpendLines, } from "./simulate.js";
|
|
5
|
+
import { roundCents, shortCardName } from "./text.js";
|
|
6
|
+
// Node-free: earnrate.dev runs it in the browser.
|
|
7
|
+
// A fee card shows up this many days before its anniversary, and stays
|
|
8
|
+
// until this many after: the fee posts on the statement after it.
|
|
9
|
+
export const RENEWAL_BEFORE = 30;
|
|
10
|
+
export const RENEWAL_AFTER = 15;
|
|
11
|
+
// The card's anniversary as MM-DD: the saved one, or from the day it opened.
|
|
12
|
+
export function anniversaryOf(held) {
|
|
13
|
+
return (held.anniversary ?? (held.opened ?? held.bonus?.opened)?.slice(5) ?? null);
|
|
14
|
+
}
|
|
15
|
+
const openedOf = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
16
|
+
// A real date for MM-DD in `year`: Feb 29 falls on Feb 28 in other years.
|
|
17
|
+
function onYear(year, mmdd) {
|
|
18
|
+
const date = `${String(year)}-${mmdd}`;
|
|
19
|
+
if (mmdd !== '02-29')
|
|
20
|
+
return date;
|
|
21
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
22
|
+
return leap ? date : `${String(year)}-02-28`;
|
|
23
|
+
}
|
|
24
|
+
// The renewal date near `today`, from RENEWAL_BEFORE days ahead back to
|
|
25
|
+
// RENEWAL_AFTER days past; null outside that, without an anniversary, or
|
|
26
|
+
// before the account's first year is nearly over.
|
|
27
|
+
export function renewalDue(held, today) {
|
|
28
|
+
const mmdd = anniversaryOf(held);
|
|
29
|
+
if (mmdd === null || !isAnniversary(mmdd))
|
|
30
|
+
return null;
|
|
31
|
+
const opened = openedOf(held);
|
|
32
|
+
const year = Number(today.slice(0, 4));
|
|
33
|
+
for (const y of [year - 1, year, year + 1]) {
|
|
34
|
+
const due = onYear(y, mmdd);
|
|
35
|
+
const days = daysBetween(today, due);
|
|
36
|
+
if (days > RENEWAL_BEFORE || days < -RENEWAL_AFTER)
|
|
37
|
+
continue;
|
|
38
|
+
if (opened !== null && daysBetween(opened, due) < 300)
|
|
39
|
+
continue;
|
|
40
|
+
return due;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// Whether a fee card still pays for itself, for the year after `due`.
|
|
45
|
+
export function renewalCheck(catalog, wallet, card, due, spending = {
|
|
46
|
+
lines: typicalSpendLines(catalog),
|
|
47
|
+
typical: true,
|
|
48
|
+
}) {
|
|
49
|
+
const held = wallet.cards.find((c) => c.id === card.id);
|
|
50
|
+
const projected = projectedWallet(wallet, catalog, due);
|
|
51
|
+
const ids = projected.cards.map((c) => c.id);
|
|
52
|
+
const withCard = simulateWallet(catalog, projected, spending.lines, due, ids);
|
|
53
|
+
const others = projected.cards.filter((c) => c.id !== card.id);
|
|
54
|
+
let without = null;
|
|
55
|
+
let against = null;
|
|
56
|
+
if (others.length > 0) {
|
|
57
|
+
without = simulateWallet(catalog, { ...projected, cards: others }, spending.lines, due, ids.filter((id) => id !== card.id));
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const noFee = catalog.cards.filter((c) => matchesFilters(c, {
|
|
61
|
+
...ALL_PERSONAL_CARDS,
|
|
62
|
+
business: card.business,
|
|
63
|
+
noAnnualFee: true,
|
|
64
|
+
}) &&
|
|
65
|
+
c.requires_membership === undefined &&
|
|
66
|
+
c.network !== 'store');
|
|
67
|
+
const [best] = simulateSingles(catalog, noFee, spending.lines, {
|
|
68
|
+
filters: { ...ALL_PERSONAL_CARDS, noAnnualFee: true },
|
|
69
|
+
memberships: wallet.memberships,
|
|
70
|
+
valuations: wallet.valuations,
|
|
71
|
+
start: due,
|
|
72
|
+
});
|
|
73
|
+
if (best) {
|
|
74
|
+
without = best;
|
|
75
|
+
const bestId = best.cards[0]?.cardId;
|
|
76
|
+
const bestCard = catalog.cards.find((c) => c.id === bestId);
|
|
77
|
+
against = bestCard ? shortCardName(bestCard.name) : null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// From the saved card: projectedWallet drops credit usage, which is how
|
|
81
|
+
// a credit that needs no enrolling is confirmed in use.
|
|
82
|
+
const credits = held ? usedCreditsYear(catalog, card, held, due).yearly : 0;
|
|
83
|
+
const rewardsAdded = roundCents(withCard.rewards - (without?.rewards ?? 0));
|
|
84
|
+
const fee = card.annual_fee ?? 0;
|
|
85
|
+
const opened = held ? openedOf(held) : null;
|
|
86
|
+
return {
|
|
87
|
+
cardId: card.id,
|
|
88
|
+
due,
|
|
89
|
+
fee,
|
|
90
|
+
firstYearFee: opened !== null &&
|
|
91
|
+
daysBetween(opened, due) < 400 &&
|
|
92
|
+
(card.first_year_fee ?? fee) < fee
|
|
93
|
+
? (card.first_year_fee ?? 0)
|
|
94
|
+
: null,
|
|
95
|
+
rewardsAdded,
|
|
96
|
+
credits,
|
|
97
|
+
net: roundCents(rewardsAdded + credits - fee),
|
|
98
|
+
against,
|
|
99
|
+
typical: spending.typical,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES,
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, choicePhrase, wholeDollars, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -31,22 +31,19 @@ export function ruleParts(catalog, rule, card, { everyStore = false } = {}) {
|
|
|
31
31
|
return [
|
|
32
32
|
rule.geography === 'foreign_only'
|
|
33
33
|
? 'purchases outside the U.S.'
|
|
34
|
-
: rule.
|
|
35
|
-
?
|
|
36
|
-
:
|
|
37
|
-
? '
|
|
38
|
-
:
|
|
34
|
+
: rule.min_amount !== undefined
|
|
35
|
+
? `purchases of ${wholeDollars(rule.min_amount)} or more`
|
|
36
|
+
: rule.requires?.payment !== undefined
|
|
37
|
+
? 'purchases'
|
|
38
|
+
: card?.rules.every((r) => r.match.any)
|
|
39
|
+
? 'every purchase'
|
|
40
|
+
: 'everything else',
|
|
39
41
|
];
|
|
40
42
|
if (m.choice !== undefined) {
|
|
41
43
|
const choice = card?.choices.find((c) => c.id === m.choice);
|
|
42
44
|
if (!choice)
|
|
43
45
|
return [`your ${choiceName(m.choice)} picks`];
|
|
44
|
-
|
|
45
|
-
? 'the category'
|
|
46
|
-
: `the ${String(choice.pick)} categories`;
|
|
47
|
-
return [
|
|
48
|
-
`${which} you pick${choice.period === 'until_changed' ? '' : ` ${PICK_PERIODS[choice.period]}`}`,
|
|
49
|
-
];
|
|
46
|
+
return [choicePhrase(choice)];
|
|
50
47
|
}
|
|
51
48
|
const merchants = m.merchants ?? [];
|
|
52
49
|
const categories = (m.categories ?? []).map((c) => categoryPhrase(catalog, c));
|
|
@@ -85,7 +82,10 @@ export function shortenedMerchants(catalog, merchants) {
|
|
|
85
82
|
}
|
|
86
83
|
export function ruleWhat(catalog, rule, card, options = {}) {
|
|
87
84
|
const payment = rule.requires?.payment;
|
|
88
|
-
const
|
|
85
|
+
const parts = listed(ruleParts(catalog, rule, card, options));
|
|
86
|
+
const what = rule.min_amount === undefined || rule.match.any
|
|
87
|
+
? parts
|
|
88
|
+
: `${parts}, on purchases of ${wholeDollars(rule.min_amount)} or more`;
|
|
89
89
|
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
90
90
|
}
|
|
91
91
|
// "on dining", "on your picks", "at Costco", "on Walmart+ membership": a
|
|
@@ -141,6 +141,14 @@ export function creditName(catalog, credit) {
|
|
|
141
141
|
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
142
142
|
]);
|
|
143
143
|
}
|
|
144
|
+
// A short name for a list of credits: the first store a credit names
|
|
145
|
+
// ("Grubhub" for the dining credit at five chains); creditName has them all.
|
|
146
|
+
export function creditLabel(catalog, credit) {
|
|
147
|
+
const [first, ...more] = credit.match.merchants ?? [];
|
|
148
|
+
return first === undefined || more.length === 0
|
|
149
|
+
? creditName(catalog, credit)
|
|
150
|
+
: merchantName(catalog, first);
|
|
151
|
+
}
|
|
144
152
|
// "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
|
|
145
153
|
// the category's or merchant's own preposition.
|
|
146
154
|
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
|
-
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
|
-
import { milestoneWhat } from "./milestones.js";
|
|
2
|
+
import { effectiveMemberships, membership, membershipName, sameGroup, } from "./memberships.js";
|
|
3
|
+
import { milestoneStarts, milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
6
|
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
-
import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
|
|
7
|
+
import { choicePhrase, creditBack, creditTerms, ENROLL_FIRST, idList, listed, possessive, } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -89,6 +89,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
89
89
|
id: c.id,
|
|
90
90
|
pick: c.pick,
|
|
91
91
|
period: c.period,
|
|
92
|
+
automatic: c.automatic === true,
|
|
92
93
|
options: Object.keys(c.options),
|
|
93
94
|
default: c.default ?? null,
|
|
94
95
|
})),
|
|
@@ -108,7 +109,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
108
109
|
period: m.period,
|
|
109
110
|
points: m.points ?? null,
|
|
110
111
|
rewards: m.rewards ?? [],
|
|
111
|
-
text: milestoneWhat(m).replace(/^the /, ''),
|
|
112
|
+
text: `${milestoneWhat(m).replace(/^the /, '')}${milestoneStarts(m, today)}`,
|
|
112
113
|
})),
|
|
113
114
|
introRates: card.intro_rates.map((i) => ({
|
|
114
115
|
id: i.id,
|
|
@@ -123,7 +124,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
123
124
|
amount: c.amount,
|
|
124
125
|
period: c.period,
|
|
125
126
|
enroll: c.enroll === true,
|
|
126
|
-
text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
127
|
+
text: `${creditBack(c)} ${creditWhere(catalog, c)}${creditTerms(c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
127
128
|
};
|
|
128
129
|
}),
|
|
129
130
|
};
|
|
@@ -172,12 +173,15 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
172
173
|
notes.push({
|
|
173
174
|
kind: 'pick',
|
|
174
175
|
text: choice.default && picked.has(choice.id)
|
|
175
|
-
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
176
|
-
:
|
|
176
|
+
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if ${choice.automatic ? 'you expect a different one to have the most spending' : 'a different one was picked'} (${String(choice.pick)} of: ${options}).`
|
|
177
|
+
: choice.automatic
|
|
178
|
+
? `${name} applies its bonus to ${choicePhrase(choice)}; set its ${choice.id} pick to the one you expect (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`
|
|
179
|
+
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
// With one tier of a group saved, the others aren't missing.
|
|
180
|
-
const
|
|
183
|
+
const has = effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id));
|
|
184
|
+
const missing = membershipsUsed(card).filter((m) => !has.some((held) => held === m || sameGroup(catalog, held, m)));
|
|
181
185
|
if (missing.length > 0) {
|
|
182
186
|
notes.push({
|
|
183
187
|
kind: 'membership',
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
|
|
2
|
+
import { allCreditsYear, usedCreditsYear } from "./credits.js";
|
|
2
3
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
4
|
import { idealHolding, matchesFilters, } from "./explore.js";
|
|
4
5
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
7
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
|
-
import { milestoneWhat } from "./milestones.js";
|
|
8
|
+
import { liveMilestones, milestoneStarts, milestoneWhat } from "./milestones.js";
|
|
8
9
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
|
|
10
|
+
import { creditsCountedText, money, paymentNote, roundCents, shortCardName, wholeDollars, possessive, } from "./text.js";
|
|
10
11
|
import { cardValuation } from "./valuation.js";
|
|
11
12
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
13
|
const MONTHS = 12;
|
|
@@ -127,9 +128,11 @@ held = []) {
|
|
|
127
128
|
// cardmember-year total is the same twelve months' spending.
|
|
128
129
|
const milestones = [];
|
|
129
130
|
const milestoneNotes = [];
|
|
131
|
+
const lastMonth = monthDates(start).at(-1) ?? start;
|
|
130
132
|
for (const { card, cents } of cards) {
|
|
131
133
|
const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
|
|
132
|
-
|
|
134
|
+
// One that starts during the year counts, as it will the year after.
|
|
135
|
+
for (const m of liveMilestones(card, lastMonth)) {
|
|
133
136
|
if (spent + EPSILON < m.spend)
|
|
134
137
|
continue;
|
|
135
138
|
const value = roundCents(((m.points ?? 0) * cents) / 100);
|
|
@@ -139,12 +142,25 @@ held = []) {
|
|
|
139
142
|
points: m.points ?? null,
|
|
140
143
|
value,
|
|
141
144
|
});
|
|
142
|
-
milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
145
|
+
milestoneNotes.push(`${shortCardName(card.name)}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${milestoneStarts(m, start)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
|
|
146
149
|
milestones.reduce((sum, m) => sum + m.value, 0));
|
|
147
150
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
151
|
+
const credits = cards.flatMap(({ card }) => {
|
|
152
|
+
const holding = held.includes(card.id);
|
|
153
|
+
const raw = wallet.cards.find((h) => h.id === card.id);
|
|
154
|
+
const year = holding && raw
|
|
155
|
+
? usedCreditsYear(catalog, card, raw, start)
|
|
156
|
+
: allCreditsYear(catalog, card, start);
|
|
157
|
+
return year.creditIds.length === 0 ? [] : [{ ...year, held: holding }];
|
|
158
|
+
});
|
|
159
|
+
const creditSum = (heldOnes) => roundCents(credits
|
|
160
|
+
.filter((c) => c.held === heldOnes)
|
|
161
|
+
.reduce((sum, c) => sum + c.yearly, 0));
|
|
162
|
+
const heldCredits = creditSum(true);
|
|
163
|
+
const otherCredits = creditSum(false);
|
|
148
164
|
const paid = paidMemberships(catalog, {
|
|
149
165
|
memberships: wallet.memberships,
|
|
150
166
|
heldCards: held,
|
|
@@ -155,7 +171,12 @@ held = []) {
|
|
|
155
171
|
const notes = [...milestoneNotes];
|
|
156
172
|
for (const m of memberships)
|
|
157
173
|
notes.push(membershipNote(catalog, m));
|
|
158
|
-
const
|
|
174
|
+
for (const c of credits.filter((c) => c.held)) {
|
|
175
|
+
const card = cards.find((x) => x.card.id === c.cardId)?.card;
|
|
176
|
+
if (card)
|
|
177
|
+
notes.push(creditsCountedText(shortCardName(card.name), card.annual_fee ?? 0, c));
|
|
178
|
+
}
|
|
179
|
+
const paying = paymentNote(cards.map(({ card }) => ({ name: shortCardName(card.name), card })));
|
|
159
180
|
if (paying !== null)
|
|
160
181
|
notes.push(paying);
|
|
161
182
|
const dates = monthDates(start);
|
|
@@ -164,13 +185,13 @@ held = []) {
|
|
|
164
185
|
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
165
186
|
];
|
|
166
187
|
if (gaps.length > 0)
|
|
167
|
-
notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
188
|
+
notes.push(`${possessive(shortCardName(card.name))} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
168
189
|
if (card.annual_fee === undefined)
|
|
169
|
-
notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
|
|
190
|
+
notes.push(`${possessive(shortCardName(card.name))} annual fee isn't on file, so none is subtracted.`);
|
|
170
191
|
else if (!held.includes(card.id) &&
|
|
171
192
|
card.first_year_fee !== undefined &&
|
|
172
193
|
card.first_year_fee !== card.annual_fee)
|
|
173
|
-
notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
194
|
+
notes.push(`${possessive(shortCardName(card.name))} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
174
195
|
}
|
|
175
196
|
return {
|
|
176
197
|
cards: wallet.cards.map((h) => ({
|
|
@@ -182,7 +203,11 @@ held = []) {
|
|
|
182
203
|
annualFees,
|
|
183
204
|
memberships,
|
|
184
205
|
membershipFees,
|
|
185
|
-
|
|
206
|
+
heldCredits,
|
|
207
|
+
net: roundCents(rewards + heldCredits - annualFees - membershipFees),
|
|
208
|
+
otherCredits,
|
|
209
|
+
netIfCreditsUsed: roundCents(rewards + heldCredits + otherCredits - annualFees - membershipFees),
|
|
210
|
+
credits,
|
|
186
211
|
lines: lineResults,
|
|
187
212
|
milestones,
|
|
188
213
|
notes,
|
|
@@ -300,15 +325,16 @@ export function simulateCatalog(catalog, lines, options) {
|
|
|
300
325
|
return { singles, pairs: foldTies(pairs).slice(0, 10) };
|
|
301
326
|
}
|
|
302
327
|
// The person's own wallet, set up as it would run all year: their picks carry
|
|
303
|
-
// over, rotating categories get activated, caps start fresh.
|
|
304
|
-
|
|
328
|
+
// over, rotating categories get activated, caps start fresh. A card earnrate
|
|
329
|
+
// no longer lists is left out, since it can't be simulated.
|
|
330
|
+
export function projectedWallet(wallet, catalog, start) {
|
|
305
331
|
return {
|
|
306
332
|
...wallet,
|
|
307
|
-
cards: wallet.cards.
|
|
333
|
+
cards: wallet.cards.flatMap((held) => {
|
|
308
334
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
335
|
+
if (!card)
|
|
336
|
+
return [];
|
|
337
|
+
const ideal = idealHolding(card, start, held.chosen?.picks ?? {});
|
|
312
338
|
return {
|
|
313
339
|
...ideal,
|
|
314
340
|
...(held.checkout_label !== undefined
|
|
@@ -317,6 +343,14 @@ function projectedWallet(wallet, catalog, start) {
|
|
|
317
343
|
...(held.anniversary !== undefined
|
|
318
344
|
? { anniversary: held.anniversary }
|
|
319
345
|
: {}),
|
|
346
|
+
...(held.enrolled !== undefined ? { enrolled: held.enrolled } : {}),
|
|
347
|
+
...(held.skipped_credits !== undefined
|
|
348
|
+
? { skipped_credits: held.skipped_credits }
|
|
349
|
+
: {}),
|
|
350
|
+
// Usage confirms a credit that needs no enrolling is in use.
|
|
351
|
+
...(held.credit_usage !== undefined
|
|
352
|
+
? { credit_usage: held.credit_usage }
|
|
353
|
+
: {}),
|
|
320
354
|
};
|
|
321
355
|
}),
|
|
322
356
|
};
|
|
@@ -328,7 +362,9 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
328
362
|
const held = new Set(heldIds);
|
|
329
363
|
const additions = [];
|
|
330
364
|
for (const card of catalog.cards) {
|
|
331
|
-
if (held.has(card.id) ||
|
|
365
|
+
if (held.has(card.id) ||
|
|
366
|
+
!matchesFilters(card, options.filters) ||
|
|
367
|
+
(wallet.skipped_issuers ?? []).includes(card.issuer))
|
|
332
368
|
continue;
|
|
333
369
|
const simulation = bestSimulation(catalog, projected.cards, card, lines, {
|
|
334
370
|
...options,
|
|
@@ -339,13 +375,17 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
339
375
|
const extraRewards = roundCents(simulation.rewards - current.rewards);
|
|
340
376
|
const annualFee = card.annual_fee ?? 0;
|
|
341
377
|
const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
|
|
378
|
+
const gain = roundCents(extraRewards - annualFee - membershipFee);
|
|
379
|
+
const credits = allCreditsYear(catalog, card, options.start).yearly;
|
|
342
380
|
additions.push({
|
|
343
381
|
cardId: card.id,
|
|
344
382
|
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
345
383
|
extraRewards,
|
|
346
384
|
annualFee,
|
|
347
385
|
membershipFee,
|
|
348
|
-
gain
|
|
386
|
+
gain,
|
|
387
|
+
credits,
|
|
388
|
+
gainIfCreditsUsed: roundCents(gain + credits),
|
|
349
389
|
simulation,
|
|
350
390
|
});
|
|
351
391
|
}
|