earnrate 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/data/CHANGELOG.md +181 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +28 -2
- package/data/cards/amex-blue-cash-preferred.yaml +19 -0
- package/data/cards/amex-business-gold.yaml +38 -3
- package/data/cards/amex-business-platinum.yaml +72 -3
- package/data/cards/amex-delta-skymiles-gold.yaml +27 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
- package/data/cards/amex-gold.yaml +52 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
- package/data/cards/amex-hilton-honors-business.yaml +14 -1
- package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
- package/data/cards/amex-platinum.yaml +110 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bilt-obsidian.yaml +12 -2
- package/data/cards/bilt-palladium.yaml +12 -2
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-venture-x-business.yaml +28 -1
- package/data/cards/capital-one-venture-x.yaml +30 -2
- package/data/cards/chase-aeroplan.yaml +14 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
- package/data/cards/chase-ink-business-cash.yaml +13 -0
- package/data/cards/chase-ink-business-unlimited.yaml +13 -2
- package/data/cards/chase-sapphire-preferred.yaml +11 -0
- package/data/cards/chase-sapphire-reserve.yaml +44 -4
- package/data/cards/chase-united-business.yaml +32 -0
- package/data/cards/chase-united-club-business.yaml +40 -0
- package/data/cards/chase-united-club.yaml +40 -0
- package/data/cards/chase-united-explorer.yaml +32 -0
- package/data/cards/chase-united-quest.yaml +40 -0
- package/data/cards/citi-aadvantage-executive.yaml +26 -1
- package/data/cards/citi-strata-elite.yaml +20 -1
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +13 -2
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/usbank-altitude-connect.yaml +8 -2
- package/data/cards/usbank-altitude-go.yaml +8 -2
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/currencies.yaml +14 -0
- package/data/issuers.yaml +22 -0
- package/data/memberships.yaml +35 -0
- package/data/merchants/overrides.yaml +448 -0
- package/data/merchants/seed.yaml +1082 -65
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +30 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +22 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +25 -6
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +25 -4
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +38 -11
- package/dist/engine/explore.js +70 -7
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +72 -20
- package/dist/engine/record.js +41 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +14 -3
- package/dist/engine/setup.js +15 -4
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +84 -0
- package/dist/engine/wallet-change.js +37 -1
- package/dist/engine/wallet-summary.js +32 -4
- package/dist/format/card-text.js +4 -3
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet.js +6 -2
- package/dist/import/analyze.js +7 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +70 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +15 -7
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +37 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +8 -0
- package/package.json +1 -1
package/dist/engine/explore.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
|
-
import {
|
|
2
|
+
import { creditCovers, creditPerYear } from "./credits.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { creditName } from "./rule-text.js";
|
|
3
5
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
7
|
import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
|
|
6
8
|
import { rotationGap } from "./rotation.js";
|
|
7
9
|
import { membershipsUsed } from "./setup.js";
|
|
8
10
|
import { optionName } from "./rule-text.js";
|
|
9
|
-
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
11
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
12
|
import { cardValuation } from "./valuation.js";
|
|
11
13
|
import { EPSILON } from "./compare.js";
|
|
12
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -163,11 +165,13 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
163
165
|
channel: query.channel,
|
|
164
166
|
date: query.date,
|
|
165
167
|
country: query.country ?? 'US',
|
|
168
|
+
payment: query.payment ?? null,
|
|
166
169
|
};
|
|
167
170
|
const ranked = [];
|
|
171
|
+
const ifPaid = new Map();
|
|
168
172
|
const excluded = [];
|
|
169
173
|
// A Costco purchase means a Costco member: no membership to point out.
|
|
170
|
-
const
|
|
174
|
+
const members = paidMemberships(catalog, {
|
|
171
175
|
memberships: options.memberships,
|
|
172
176
|
merchantIds: [merchant.merchantId],
|
|
173
177
|
});
|
|
@@ -177,7 +181,9 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
177
181
|
const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
|
|
178
182
|
const held = idealHolding(card, query.date, picks);
|
|
179
183
|
const wallet = walletOf(held, options);
|
|
180
|
-
const rec = recommend(catalog, wallet, purchase, merchant
|
|
184
|
+
const rec = recommend(catalog, wallet, purchase, merchant, {
|
|
185
|
+
credits: false,
|
|
186
|
+
});
|
|
181
187
|
const top = rec.ranked[0];
|
|
182
188
|
if (!top) {
|
|
183
189
|
excluded.push(...rec.excluded);
|
|
@@ -185,6 +191,16 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
185
191
|
}
|
|
186
192
|
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
187
193
|
const gap = rotationGap(card, query.date);
|
|
194
|
+
const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
195
|
+
if (paid)
|
|
196
|
+
ifPaid.set(card.id, {
|
|
197
|
+
payment: paid.payment,
|
|
198
|
+
earn: earnText(catalog, paid.nominalRate, top.currency),
|
|
199
|
+
standard: earnText(catalog, top.rate, top.currency),
|
|
200
|
+
returnPct: paid.rate * top.centsPerUnit - (top.foreignFee ?? 0),
|
|
201
|
+
});
|
|
202
|
+
const payment = card.rules.find((r) => r.id === top.matchedRule)?.requires
|
|
203
|
+
?.payment;
|
|
188
204
|
const evidenceRule = top.matchedRule ?? 'base_rate';
|
|
189
205
|
const evidence = card.evidence.find((e) => e.rule === evidenceRule);
|
|
190
206
|
ranked.push({
|
|
@@ -203,7 +219,8 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
203
219
|
requirements,
|
|
204
220
|
conditions: [
|
|
205
221
|
...conditionsText(catalog, card, requirements),
|
|
206
|
-
...
|
|
222
|
+
...(payment === undefined ? [] : [`Paid ${PAYMENT_PHRASES[payment]}.`]),
|
|
223
|
+
...membershipNeeded(catalog, card, members),
|
|
207
224
|
],
|
|
208
225
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
209
226
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
@@ -212,13 +229,48 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
212
229
|
ranked.sort(compareExplored);
|
|
213
230
|
return {
|
|
214
231
|
merchant,
|
|
232
|
+
credits: creditPicks(catalog, merchant, ranked),
|
|
215
233
|
ranked,
|
|
216
234
|
excluded,
|
|
217
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
|
|
235
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
|
|
218
236
|
warnings: exploreWarnings(catalog, merchant),
|
|
219
237
|
};
|
|
220
238
|
}
|
|
221
|
-
function
|
|
239
|
+
function creditPicks(catalog, merchant, ranked) {
|
|
240
|
+
const picks = ranked.flatMap((entry) => {
|
|
241
|
+
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
242
|
+
return ((card?.credits ?? [])
|
|
243
|
+
// A credit for a whole category (every restaurant) isn't a store's.
|
|
244
|
+
.filter((c) => c.match.merchants !== undefined &&
|
|
245
|
+
creditCovers(catalog, c, merchant))
|
|
246
|
+
.map((credit) => {
|
|
247
|
+
const earn = earnText(catalog, entry.rate, entry.currency);
|
|
248
|
+
const evidence = card?.evidence.find((e) => e.rule === credit.id);
|
|
249
|
+
return {
|
|
250
|
+
cardId: entry.cardId,
|
|
251
|
+
cardName: entry.cardName,
|
|
252
|
+
issuer: entry.issuer,
|
|
253
|
+
network: entry.network,
|
|
254
|
+
annualFee: entry.annualFee,
|
|
255
|
+
firstYearFee: entry.firstYearFee,
|
|
256
|
+
creditId: credit.id,
|
|
257
|
+
name: creditName(catalog, credit),
|
|
258
|
+
amount: credit.amount,
|
|
259
|
+
period: credit.period,
|
|
260
|
+
enroll: credit.enroll === true,
|
|
261
|
+
note: credit.note ?? null,
|
|
262
|
+
back: creditBack(credit),
|
|
263
|
+
earn,
|
|
264
|
+
text: creditThen(credit, earn),
|
|
265
|
+
evidence: evidence
|
|
266
|
+
? { quote: evidence.quote, url: evidence.url }
|
|
267
|
+
: null,
|
|
268
|
+
};
|
|
269
|
+
}));
|
|
270
|
+
});
|
|
271
|
+
return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
|
|
272
|
+
}
|
|
273
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
|
|
222
274
|
const assumptions = [];
|
|
223
275
|
if (merchant.source === 'category') {
|
|
224
276
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -235,6 +287,17 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
|
|
|
235
287
|
assumptions.push(values);
|
|
236
288
|
if (isForeign(purchase))
|
|
237
289
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
290
|
+
if (purchase.payment)
|
|
291
|
+
assumptions.push(paidText(purchase.payment));
|
|
292
|
+
const top = ranked[0];
|
|
293
|
+
for (const entry of ranked) {
|
|
294
|
+
const paid = ifPaid.get(entry.cardId);
|
|
295
|
+
if (!paid || !top)
|
|
296
|
+
continue;
|
|
297
|
+
if (entry !== top && paid.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
298
|
+
continue;
|
|
299
|
+
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
300
|
+
}
|
|
238
301
|
return assumptions;
|
|
239
302
|
}
|
|
240
303
|
function exploreWarnings(catalog, merchant) {
|
package/dist/engine/offers.js
CHANGED
|
@@ -2,7 +2,7 @@ import { bonusDollars } from "./bonus.js";
|
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
4
|
import { inEffect } from "./periods.js";
|
|
5
|
-
import { money, roundCents } from "./text.js";
|
|
5
|
+
import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
|
|
6
6
|
import { cardValuation } from "./valuation.js";
|
|
7
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
8
8
|
const held = wallet.cards
|
|
@@ -83,6 +83,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
83
83
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
84
84
|
`Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
|
|
85
85
|
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
|
|
86
|
+
CREDITS_LEFT_OUT,
|
|
86
87
|
];
|
|
87
88
|
return { offers, withoutOffer, notes };
|
|
88
89
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Keys match the wallet's PeriodKey format: "2026"
|
|
2
|
-
// year is keyed by the calendar year it
|
|
3
|
-
// cardmember year is asked for without an
|
|
1
|
+
// Keys match the wallet's PeriodKey format: "2026", "2026-Q4" or "2026-H2"
|
|
2
|
+
// (July to December). A cardmember year is keyed by the calendar year it
|
|
3
|
+
// started in. Returns null when a cardmember year is asked for without an
|
|
4
|
+
// anniversary (MM-DD).
|
|
4
5
|
export function periodKey(period, date, anniversary) {
|
|
5
6
|
const year = Number(date.slice(0, 4));
|
|
6
7
|
const month = Number(date.slice(5, 7));
|
|
@@ -11,6 +12,8 @@ export function periodKey(period, date, anniversary) {
|
|
|
11
12
|
return String(year);
|
|
12
13
|
case 'quarter':
|
|
13
14
|
return `${String(year)}-Q${String(Math.floor((month - 1) / 3) + 1)}`;
|
|
15
|
+
case 'half_year':
|
|
16
|
+
return `${String(year)}-H${month <= 6 ? '1' : '2'}`;
|
|
14
17
|
case 'cardmember_year':
|
|
15
18
|
if (anniversary === null)
|
|
16
19
|
return null;
|
|
@@ -57,6 +60,10 @@ export function periodEnd(period, key, anniversary) {
|
|
|
57
60
|
const q = /^(\d{4})-Q([1-4])$/.exec(key);
|
|
58
61
|
return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
|
|
59
62
|
}
|
|
63
|
+
case 'half_year': {
|
|
64
|
+
const h = /^(\d{4})-H([12])$/.exec(key);
|
|
65
|
+
return h ? utcDay(Number(h[1]), Number(h[2]) * 6 + 1, 0) : null;
|
|
66
|
+
}
|
|
60
67
|
case 'calendar_year':
|
|
61
68
|
return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
|
|
62
69
|
case 'cardmember_year': {
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
-
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { openedOn } from "./intro.js";
|
|
5
5
|
import { membershipName } from "./memberships.js";
|
|
6
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
7
7
|
import { daysBetween } from "./periods.js";
|
|
8
|
+
import { creditAt, creditDollars } from "./credits.js";
|
|
8
9
|
import { ruleWhat } from "./rule-text.js";
|
|
9
10
|
import { rotationGap } from "./rotation.js";
|
|
10
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
11
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
12
|
+
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
12
13
|
import { cardValuation } from "./valuation.js";
|
|
13
14
|
export const STALE_AFTER_DAYS = 180;
|
|
14
15
|
// The engine only tells the U.S. from everywhere else.
|
|
@@ -42,7 +43,7 @@ function compareEntries(a, b) {
|
|
|
42
43
|
return aCapped ? 1 : -1;
|
|
43
44
|
return a.card.id.localeCompare(b.card.id);
|
|
44
45
|
}
|
|
45
|
-
function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
46
|
+
function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
|
|
46
47
|
const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
|
|
47
48
|
const entries = [];
|
|
48
49
|
const excluded = [];
|
|
@@ -92,6 +93,14 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
92
93
|
days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
|
|
93
94
|
}
|
|
94
95
|
: null;
|
|
96
|
+
const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
|
|
97
|
+
const credit = options.credits === false
|
|
98
|
+
? null
|
|
99
|
+
: creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
|
|
100
|
+
const back = credit ? creditDollars(credit, purchase.amount) : null;
|
|
101
|
+
const creditPct = back !== null && back > 0 && purchase.amount
|
|
102
|
+
? (back / purchase.amount) * 100
|
|
103
|
+
: 0;
|
|
95
104
|
entries.push({
|
|
96
105
|
card,
|
|
97
106
|
held,
|
|
@@ -100,10 +109,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
100
109
|
milestones,
|
|
101
110
|
perDollar,
|
|
102
111
|
fee,
|
|
103
|
-
|
|
112
|
+
credit,
|
|
113
|
+
creditDollars: back,
|
|
114
|
+
creditPct,
|
|
115
|
+
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
|
|
104
116
|
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
105
|
-
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
106
|
-
|
|
117
|
+
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
118
|
+
fee +
|
|
119
|
+
creditPct,
|
|
120
|
+
ifNew: ifNew && { ...ifNew, returnPct: ifNew.returnPct + creditPct },
|
|
121
|
+
ifPaid: paid
|
|
122
|
+
? {
|
|
123
|
+
payment: paid.payment,
|
|
124
|
+
rate: paid.nominalRate,
|
|
125
|
+
returnPct: (perDollar - evaluation.best.rate + paid.rate) *
|
|
126
|
+
valuation.centsPerUnit -
|
|
127
|
+
fee +
|
|
128
|
+
creditPct,
|
|
129
|
+
}
|
|
130
|
+
: null,
|
|
107
131
|
});
|
|
108
132
|
}
|
|
109
133
|
entries.sort(compareEntries);
|
|
@@ -133,6 +157,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
133
157
|
}
|
|
134
158
|
for (const hit of entry.milestones)
|
|
135
159
|
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
160
|
+
if (entry.credit)
|
|
161
|
+
lines.push(creditAnswerText(entry.credit, entry.creditDollars));
|
|
136
162
|
if (valuation.pooledFrom !== null) {
|
|
137
163
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
138
164
|
const heldPartners = wallet.cards
|
|
@@ -218,6 +244,16 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
218
244
|
entry.valuation.centsPerUnit) /
|
|
219
245
|
100),
|
|
220
246
|
},
|
|
247
|
+
credit: entry.credit && {
|
|
248
|
+
id: entry.credit.credit.id,
|
|
249
|
+
name: entry.credit.name,
|
|
250
|
+
amount: entry.credit.amount,
|
|
251
|
+
period: entry.credit.period,
|
|
252
|
+
enrolled: entry.credit.enrolled,
|
|
253
|
+
left: entry.credit.left,
|
|
254
|
+
until: entry.credit.until,
|
|
255
|
+
dollars: entry.creditDollars,
|
|
256
|
+
},
|
|
221
257
|
capped: evaluation.best.cap !== null,
|
|
222
258
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
223
259
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -262,15 +298,16 @@ function sensitivity(catalog, entries) {
|
|
|
262
298
|
continue;
|
|
263
299
|
const wRate = winner.perDollar;
|
|
264
300
|
const oRate = other.perDollar;
|
|
265
|
-
// Net return is rate × value − fee, so each flip point adds the
|
|
301
|
+
// Net return is rate × value − fee + credit, so each flip point adds the
|
|
302
|
+
// fee back and takes the credit off.
|
|
266
303
|
if (w.range && wRate > 0) {
|
|
267
|
-
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
304
|
+
const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
|
|
268
305
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
269
306
|
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
270
307
|
}
|
|
271
308
|
}
|
|
272
309
|
if (o.range && oRate > 0) {
|
|
273
|
-
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
310
|
+
const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
|
|
274
311
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
275
312
|
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
276
313
|
}
|
|
@@ -292,8 +329,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
292
329
|
const naming = entries.flatMap((e) => {
|
|
293
330
|
const rule = e.evaluation.best.rule;
|
|
294
331
|
return rule &&
|
|
295
|
-
merchant.merchantId
|
|
296
|
-
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
332
|
+
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
297
333
|
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
298
334
|
: [];
|
|
299
335
|
});
|
|
@@ -310,6 +346,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
310
346
|
if (purchase.channel === null && merchant.channel === 'in_store') {
|
|
311
347
|
assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
|
|
312
348
|
}
|
|
349
|
+
if (merchant.channel === null && purchase.payment === 'paypal') {
|
|
350
|
+
assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
|
|
351
|
+
}
|
|
313
352
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
314
353
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
315
354
|
}
|
|
@@ -318,7 +357,18 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
318
357
|
assumptions.push(values);
|
|
319
358
|
if (isForeign(purchase))
|
|
320
359
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
360
|
+
if (purchase.payment)
|
|
361
|
+
assumptions.push(paidText(purchase.payment));
|
|
321
362
|
const winner = entries[0];
|
|
363
|
+
for (const e of entries) {
|
|
364
|
+
if (!e.ifPaid || !winner)
|
|
365
|
+
continue;
|
|
366
|
+
if (e !== winner &&
|
|
367
|
+
e.ifPaid.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
368
|
+
continue;
|
|
369
|
+
const currency = e.valuation.currency;
|
|
370
|
+
assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
|
|
371
|
+
}
|
|
322
372
|
for (const e of entries) {
|
|
323
373
|
if (!e.ifNew || !winner)
|
|
324
374
|
continue;
|
|
@@ -333,8 +383,8 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
333
383
|
assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
|
|
334
384
|
return assumptions;
|
|
335
385
|
}
|
|
336
|
-
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
337
|
-
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category);
|
|
386
|
+
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel), options = {}) {
|
|
387
|
+
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category, options);
|
|
338
388
|
const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
|
|
339
389
|
const winner = entries[0];
|
|
340
390
|
const warnings = [];
|
|
@@ -358,7 +408,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
358
408
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
359
409
|
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
360
410
|
for (const category of merchant.ambiguity.mayCodeAs) {
|
|
361
|
-
const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
|
|
411
|
+
const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
|
|
362
412
|
const alt = alts[0];
|
|
363
413
|
if (!alt || !winner)
|
|
364
414
|
continue;
|
|
@@ -385,7 +435,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
385
435
|
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
386
436
|
: null;
|
|
387
437
|
const alt = online
|
|
388
|
-
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
438
|
+
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
|
|
389
439
|
: undefined;
|
|
390
440
|
if (alt)
|
|
391
441
|
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
|
|
@@ -410,8 +460,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
410
460
|
const via = resolveMerchantById(catalog, id, purchase.channel);
|
|
411
461
|
if (!via || !winner)
|
|
412
462
|
continue;
|
|
413
|
-
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
414
|
-
.entries[0];
|
|
463
|
+
const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
|
|
415
464
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
416
465
|
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
|
|
417
466
|
}
|
|
@@ -447,8 +496,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
447
496
|
const costHere = ties || top.earned === null || first.earned === null
|
|
448
497
|
? null
|
|
449
498
|
: roundCents(first.earned +
|
|
450
|
-
(first.milestoneBonus?.dollars ?? 0)
|
|
451
|
-
(
|
|
499
|
+
(first.milestoneBonus?.dollars ?? 0) +
|
|
500
|
+
(first.credit?.dollars ?? 0) -
|
|
501
|
+
(top.earned +
|
|
502
|
+
(top.milestoneBonus?.dollars ?? 0) +
|
|
503
|
+
(top.credit?.dollars ?? 0)));
|
|
452
504
|
bonusResult = {
|
|
453
505
|
cardId: top.cardId,
|
|
454
506
|
checkoutLabel: top.checkoutLabel,
|
package/dist/engine/record.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { creditAt, creditDollars } from "./credits.js";
|
|
4
5
|
import { milestoneProgress } from "./milestones.js";
|
|
5
6
|
import { roundCents } from "./text.js";
|
|
6
7
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
@@ -112,14 +113,53 @@ export function applyMilestoneBooking(wallet, b) {
|
|
|
112
113
|
: c),
|
|
113
114
|
};
|
|
114
115
|
}
|
|
116
|
+
// The part of an enrolled merchant credit this purchase uses. None when the
|
|
117
|
+
// card has no credit here, it isn't enrolled, or it's used up.
|
|
118
|
+
export function creditBooking(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
119
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
120
|
+
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
121
|
+
if (!held || !card)
|
|
122
|
+
return null;
|
|
123
|
+
const state = creditAt(catalog, card, held, merchant, purchase.date);
|
|
124
|
+
const back = state ? creditDollars(state, purchase.amount) : null;
|
|
125
|
+
if (!state || state.key === null || back === null || back <= 0)
|
|
126
|
+
return null;
|
|
127
|
+
return {
|
|
128
|
+
cardId: card.id,
|
|
129
|
+
creditId: state.credit.id,
|
|
130
|
+
name: state.name,
|
|
131
|
+
period: state.key,
|
|
132
|
+
periodKind: state.period,
|
|
133
|
+
amount: state.amount,
|
|
134
|
+
before: state.used,
|
|
135
|
+
after: roundCents(state.used + back),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function applyCreditBooking(wallet, b) {
|
|
139
|
+
return {
|
|
140
|
+
...wallet,
|
|
141
|
+
cards: wallet.cards.map((c) => c.id === b.cardId
|
|
142
|
+
? {
|
|
143
|
+
...c,
|
|
144
|
+
credit_usage: {
|
|
145
|
+
...c.credit_usage,
|
|
146
|
+
[b.creditId]: { period: b.period, used: b.after },
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
: c),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
115
152
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
116
153
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
117
154
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
118
155
|
const milestones = milestoneBookings(catalog, wallet, purchase);
|
|
156
|
+
const credit = creditBooking(catalog, wallet, purchase, merchant);
|
|
119
157
|
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
120
158
|
if (bonus)
|
|
121
159
|
next = applyBonusBooking(next, bonus);
|
|
122
160
|
for (const m of milestones)
|
|
123
161
|
next = applyMilestoneBooking(next, m);
|
|
124
|
-
|
|
162
|
+
if (credit)
|
|
163
|
+
next = applyCreditBooking(next, credit);
|
|
164
|
+
return { wallet: next, cap, bonus, milestones, credit };
|
|
125
165
|
}
|
package/dist/engine/rotation.js
CHANGED
|
@@ -14,3 +14,6 @@ export function rotationGap(card, date) {
|
|
|
14
14
|
}
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
+
// What answers say about a gap: "Freedom Flex's rotating categories for
|
|
18
|
+
// 2027-Q2 aren't in earnrate's data yet. ..."
|
|
19
|
+
export const rotationGapText = (name, gap) => `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, choiceName, earnText, introPeriod, listed, num, words, } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -29,7 +29,9 @@ export function ruleParts(catalog, rule) {
|
|
|
29
29
|
return [
|
|
30
30
|
rule.geography === 'foreign_only'
|
|
31
31
|
? 'purchases outside the U.S.'
|
|
32
|
-
:
|
|
32
|
+
: rule.requires?.payment !== undefined
|
|
33
|
+
? 'purchases'
|
|
34
|
+
: 'everything else',
|
|
33
35
|
];
|
|
34
36
|
if (rule.match.choice !== undefined)
|
|
35
37
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
@@ -55,7 +57,9 @@ export function ruleParts(catalog, rule) {
|
|
|
55
57
|
return parts.length > 0 ? parts : [words(rule.id)];
|
|
56
58
|
}
|
|
57
59
|
export function ruleWhat(catalog, rule) {
|
|
58
|
-
|
|
60
|
+
const payment = rule.requires?.payment;
|
|
61
|
+
const what = listed(ruleParts(catalog, rule));
|
|
62
|
+
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
59
63
|
}
|
|
60
64
|
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
61
65
|
// picks for the first year".
|
|
@@ -71,3 +75,10 @@ export function introWhat(catalog, card, intro) {
|
|
|
71
75
|
: `${num(intro.multiply)} times ${on}`;
|
|
72
76
|
return `${change} for the ${introPeriod(intro.days)}`;
|
|
73
77
|
}
|
|
78
|
+
// What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
|
|
79
|
+
export const creditName = (catalog, credit) => listed([
|
|
80
|
+
...(credit.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
81
|
+
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
82
|
+
]);
|
|
83
|
+
// "at Lululemon", "at restaurants", "on travel": a category's own preposition.
|
|
84
|
+
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ? 'on' : 'at'} ${creditName(catalog, credit)}`;
|
package/dist/engine/setup.js
CHANGED
|
@@ -2,9 +2,9 @@ import { isActivated, pickedCategories } from "./evaluate.js";
|
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
3
|
import { milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
|
-
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import { introWhat } from "./rule-text.js";
|
|
7
|
-
import { idList, listed } from "./text.js";
|
|
5
|
+
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
|
+
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
+
import { creditBack, ENROLL_FIRST, idList, listed } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -111,6 +111,17 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
111
111
|
days: i.days,
|
|
112
112
|
text: introWhat(catalog, card, i),
|
|
113
113
|
})),
|
|
114
|
+
credits: card.credits.map((c) => {
|
|
115
|
+
const name = creditName(catalog, c);
|
|
116
|
+
return {
|
|
117
|
+
id: c.id,
|
|
118
|
+
name,
|
|
119
|
+
amount: c.amount,
|
|
120
|
+
period: c.period,
|
|
121
|
+
enroll: c.enroll === true,
|
|
122
|
+
text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
114
125
|
};
|
|
115
126
|
}
|
|
116
127
|
// Every word of the query has to match a word of the card's id, name or
|
|
@@ -186,7 +197,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
186
197
|
if (gap !== null) {
|
|
187
198
|
notes.push({
|
|
188
199
|
kind: 'rotation',
|
|
189
|
-
text:
|
|
200
|
+
text: rotationGapText(name, gap),
|
|
190
201
|
});
|
|
191
202
|
}
|
|
192
203
|
return notes;
|
package/dist/engine/simulate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { milestoneWhat } from "./milestones.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, roundCents, wholeDollars } from "./text.js";
|
|
9
|
+
import { money, paymentNote, roundCents, wholeDollars } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
12
|
const MONTHS = 12;
|
|
@@ -155,6 +155,9 @@ held = []) {
|
|
|
155
155
|
const notes = [...milestoneNotes];
|
|
156
156
|
for (const m of memberships)
|
|
157
157
|
notes.push(membershipNote(catalog, m));
|
|
158
|
+
const paying = paymentNote(cards.map(({ card }) => ({ name: card.name, card })));
|
|
159
|
+
if (paying !== null)
|
|
160
|
+
notes.push(paying);
|
|
158
161
|
const dates = monthDates(start);
|
|
159
162
|
for (const { card } of cards) {
|
|
160
163
|
const gaps = [
|
|
@@ -240,6 +243,12 @@ function bestSimulation(catalog, base, card, lines, options) {
|
|
|
240
243
|
}
|
|
241
244
|
const costs = (s) => s.annualFees + s.membershipFees;
|
|
242
245
|
const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
|
|
246
|
+
// Each card on its own with its best picks, best net first.
|
|
247
|
+
export function simulateSingles(catalog, cards, lines, options) {
|
|
248
|
+
return cards
|
|
249
|
+
.map((card) => bestSimulation(catalog, [], card, lines, options))
|
|
250
|
+
.sort(byNet);
|
|
251
|
+
}
|
|
243
252
|
// Pairs that tie and share their first card differ only in an
|
|
244
253
|
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
245
254
|
function foldTies(pairs) {
|