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