earnrate 0.2.4 → 0.2.5
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 +5 -5
- package/data/CHANGELOG.md +39 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- package/data/cards/chase-united-club.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
- package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/dist/cli/cheatsheet.js +2 -2
- package/dist/cli/explore.js +11 -10
- package/dist/cli/import.js +9 -9
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +17 -3
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +18 -2
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +75 -0
- package/dist/engine/cheat-sheet.js +14 -17
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +10 -21
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +16 -4
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +10 -23
- package/dist/engine/setup.js +14 -16
- package/dist/engine/simulate.js +5 -5
- package/dist/engine/text.js +36 -22
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +17 -0
- package/dist/engine/wallet-summary.js +24 -6
- package/dist/format/card-text.js +15 -19
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +14 -15
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +5 -5
- package/dist/format/wallet.js +4 -1
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +15 -6
- package/dist/mcp/schemas.js +8 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +28 -12
- package/dist/mcp/tools/wallet.js +58 -5
- package/dist/schema/card.js +4 -11
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/package.json +1 -1
|
@@ -1,30 +1,48 @@
|
|
|
1
|
-
import { money } from "./text.js";
|
|
1
|
+
import { money, words } from "./text.js";
|
|
2
|
+
export function membership(catalog, id) {
|
|
3
|
+
return catalog.memberships.find((m) => m.id === id);
|
|
4
|
+
}
|
|
5
|
+
export function membershipName(catalog, id) {
|
|
6
|
+
return membership(catalog, id)?.name ?? words(id);
|
|
7
|
+
}
|
|
8
|
+
// "a Costco membership", for a sentence.
|
|
9
|
+
export function withArticle(name) {
|
|
10
|
+
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
|
+
}
|
|
12
|
+
// Tiers of one membership: holding one, the others in its group aren't
|
|
13
|
+
// missing (BofA Preferred Rewards).
|
|
14
|
+
export function sameGroup(catalog, a, b) {
|
|
15
|
+
const group = membership(catalog, a)?.group;
|
|
16
|
+
return group !== undefined && group === membership(catalog, b)?.group;
|
|
17
|
+
}
|
|
2
18
|
// Memberships the person already pays for: the ones they've said, the ones a
|
|
3
19
|
// card they hold needs, and the ones their spending shows (only members shop
|
|
4
20
|
// at Costco).
|
|
5
21
|
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
6
22
|
const paid = new Set(memberships);
|
|
7
23
|
for (const card of catalog.cards) {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
if (heldCards.includes(card.id) ||
|
|
12
|
-
m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
13
|
-
paid.add(m.id);
|
|
24
|
+
const id = card.requires_membership;
|
|
25
|
+
if (id !== undefined && heldCards.includes(card.id))
|
|
26
|
+
paid.add(id);
|
|
14
27
|
}
|
|
28
|
+
for (const m of catalog.memberships)
|
|
29
|
+
if (m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
30
|
+
paid.add(m.id);
|
|
15
31
|
return paid;
|
|
16
32
|
}
|
|
17
33
|
// What these cards' memberships add, once each, leaving out the paid ones.
|
|
18
|
-
export function membershipCosts(cards, paid) {
|
|
34
|
+
export function membershipCosts(catalog, cards, paid) {
|
|
19
35
|
const costs = new Map();
|
|
20
36
|
for (const card of cards) {
|
|
21
|
-
const m = card.requires_membership
|
|
37
|
+
const m = card.requires_membership === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: membership(catalog, card.requires_membership);
|
|
22
40
|
if (!m || paid.has(m.id))
|
|
23
41
|
continue;
|
|
24
42
|
const cost = costs.get(m.id) ?? {
|
|
25
43
|
id: m.id,
|
|
26
44
|
name: m.name,
|
|
27
|
-
annualFee: m.annual_fee,
|
|
45
|
+
annualFee: m.annual_fee ?? 0,
|
|
28
46
|
cardIds: [],
|
|
29
47
|
};
|
|
30
48
|
cost.cardIds.push(card.id);
|
|
@@ -32,14 +50,22 @@ export function membershipCosts(cards, paid) {
|
|
|
32
50
|
}
|
|
33
51
|
return [...costs.values()];
|
|
34
52
|
}
|
|
53
|
+
// "a Costco membership ($65 a year)"
|
|
54
|
+
export function membershipText(catalog, id) {
|
|
55
|
+
const m = membership(catalog, id);
|
|
56
|
+
const name = withArticle(m?.name ?? words(id));
|
|
57
|
+
return m?.annual_fee === undefined
|
|
58
|
+
? name
|
|
59
|
+
: `${name} (${money(m.annual_fee)} a year)`;
|
|
60
|
+
}
|
|
35
61
|
// The card's membership as a condition, unless it's paid for.
|
|
36
|
-
export function membershipNeeded(card, paid) {
|
|
37
|
-
const
|
|
38
|
-
return
|
|
39
|
-
? [`Needs ${
|
|
62
|
+
export function membershipNeeded(catalog, card, paid) {
|
|
63
|
+
const id = card.requires_membership;
|
|
64
|
+
return id !== undefined && !paid.has(id)
|
|
65
|
+
? [`Needs ${membershipText(catalog, id)}.`]
|
|
40
66
|
: [];
|
|
41
67
|
}
|
|
42
68
|
export function membershipNote(catalog, m) {
|
|
43
69
|
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
44
|
-
return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${m.name}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
|
|
70
|
+
return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
|
|
45
71
|
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
|
-
import { roundCents } from "./text.js";
|
|
4
|
+
import { money, roundCents } from "./text.js";
|
|
5
5
|
import { cardValuation } from "./valuation.js";
|
|
6
6
|
function ineligibleText(catalog, wallet, notWith) {
|
|
7
7
|
const held = wallet.cards
|
|
@@ -13,6 +13,18 @@ function ineligibleText(catalog, wallet, notWith) {
|
|
|
13
13
|
? null
|
|
14
14
|
: `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
|
|
15
15
|
}
|
|
16
|
+
export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
|
|
17
|
+
// "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
|
|
18
|
+
// months": one wording for the site, the search pages and the CLI.
|
|
19
|
+
export function offerTerms(o, currencyName) {
|
|
20
|
+
const amount = o.points !== null && o.points !== undefined
|
|
21
|
+
? `${o.points.toLocaleString('en-US')} ${currencyName}`
|
|
22
|
+
: money(o.dollars ?? 0);
|
|
23
|
+
const spend = o.spend > 0
|
|
24
|
+
? `after ${money(o.spend)} in purchases within ${String(o.months)} month${o.months === 1 ? '' : 's'}`
|
|
25
|
+
: 'with no spending required';
|
|
26
|
+
return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
|
|
27
|
+
}
|
|
16
28
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
17
29
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
18
30
|
const paid = paidMemberships(catalog, {
|
|
@@ -37,7 +49,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
37
49
|
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
38
50
|
0;
|
|
39
51
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
40
|
-
const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
|
|
52
|
+
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
41
53
|
offers.push({
|
|
42
54
|
cardId: card.id,
|
|
43
55
|
cardName: card.name,
|
|
@@ -68,8 +80,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
68
80
|
a.cardId.localeCompare(b.cardId));
|
|
69
81
|
const notes = [
|
|
70
82
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
71
|
-
|
|
72
|
-
"First-year value is the bonus at your point values minus the first year's annual fee. It leaves out what the card earns on your spending and any credits or perks.",
|
|
83
|
+
`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.`,
|
|
84
|
+
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
|
|
73
85
|
];
|
|
74
86
|
return { offers, withoutOffer, notes };
|
|
75
87
|
}
|
package/dist/engine/profiles.js
CHANGED
|
@@ -2,6 +2,14 @@ export const VALUATION_PROFILES = {
|
|
|
2
2
|
flat: { MR: 1.0, UR: 1.0 },
|
|
3
3
|
'points-heavy': { MR: 1.7, UR: 1.7 },
|
|
4
4
|
};
|
|
5
|
+
// "MR and UR at 1.7¢": what a profile sets, for help text.
|
|
6
|
+
export function profileText(profile) {
|
|
7
|
+
const values = Object.entries(VALUATION_PROFILES[profile]);
|
|
8
|
+
const cents = [...new Set(values.map(([, v]) => v))];
|
|
9
|
+
return cents.length === 1
|
|
10
|
+
? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
|
|
11
|
+
: values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
|
|
12
|
+
}
|
|
5
13
|
export function isValuationProfile(name) {
|
|
6
14
|
return Object.hasOwn(VALUATION_PROFILES, name);
|
|
7
15
|
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -3,21 +3,13 @@ import { bonusStatus } from "./bonus.js";
|
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import {
|
|
6
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
7
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
7
8
|
import { cardValuation } from "./valuation.js";
|
|
8
|
-
// An on-pace welcome bonus card is mentioned when it's this close to the
|
|
9
|
-
// best card: within a point, and at least three quarters of its rate.
|
|
10
|
-
const CLOSE_PCT = 1;
|
|
11
|
-
const CLOSE_SHARE = 0.75;
|
|
12
|
-
const EPSILON = 1e-9;
|
|
13
9
|
export const STALE_AFTER_DAYS = 180;
|
|
10
|
+
// The warning when no card works; the CLI shows it as the answer instead.
|
|
11
|
+
export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
|
|
14
12
|
const NEAR_CAP_SHARE = 0.2;
|
|
15
|
-
const PERIOD_WORDS = {
|
|
16
|
-
month: 'monthly',
|
|
17
|
-
quarter: 'quarterly',
|
|
18
|
-
calendar_year: 'calendar-year',
|
|
19
|
-
cardmember_year: 'cardmember-year',
|
|
20
|
-
};
|
|
21
13
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
22
14
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
23
15
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
@@ -121,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
121
113
|
const { best, nominal } = evaluation;
|
|
122
114
|
const warnings = [];
|
|
123
115
|
const capLabel = (e) => e.cap
|
|
124
|
-
? `${money(e.cap.cap.amount)} ${
|
|
116
|
+
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
125
117
|
: '';
|
|
126
118
|
if (best.cap && best.rule) {
|
|
127
119
|
const { remaining, cap, period } = best.cap;
|
|
@@ -292,7 +284,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
292
284
|
}
|
|
293
285
|
}
|
|
294
286
|
if (!winner) {
|
|
295
|
-
warnings.push(
|
|
287
|
+
warnings.push(noCardText(merchant.resolvedName));
|
|
296
288
|
// Only where the merchant's data says what its website takes: without
|
|
297
289
|
// that, every network is assumed, and a gas pump has no website.
|
|
298
290
|
const record = [
|
|
@@ -348,10 +340,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
348
340
|
const ties = !isBest && gap <= EPSILON;
|
|
349
341
|
const bonus = open && (open.behind || ties) ? open : null;
|
|
350
342
|
const best = winner?.effectiveReturnPct ?? 0;
|
|
351
|
-
if (open &&
|
|
352
|
-
!bonus &&
|
|
353
|
-
gap <= CLOSE_PCT &&
|
|
354
|
-
open.entry.effectiveReturnPct >= best * CLOSE_SHARE) {
|
|
343
|
+
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
355
344
|
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
345
|
}
|
|
357
346
|
const chosen = bonus?.entry ?? winner;
|
|
@@ -369,7 +358,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
369
358
|
const daysLeft = status.daysLeft ?? 0;
|
|
370
359
|
const costHere = ties || top.earned === null || first.earned === null
|
|
371
360
|
? null
|
|
372
|
-
:
|
|
361
|
+
: roundCents(first.earned - top.earned);
|
|
373
362
|
bonusResult = {
|
|
374
363
|
cardId: top.cardId,
|
|
375
364
|
checkoutLabel: top.checkoutLabel,
|
|
@@ -380,11 +369,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
380
369
|
costHere,
|
|
381
370
|
};
|
|
382
371
|
const pace = daysLeft >= 7
|
|
383
|
-
? ` (about ${
|
|
372
|
+
? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
|
|
384
373
|
: '';
|
|
385
|
-
const worth = status.value === null
|
|
386
|
-
? ''
|
|
387
|
-
: `, worth about ${money(Math.round(status.value))}`;
|
|
374
|
+
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
388
375
|
notes.unshift(ties
|
|
389
376
|
? `${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
377
|
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
2
3
|
import { daysBetween } from "./periods.js";
|
|
3
4
|
import { rotationGap } from "./rotation.js";
|
|
4
5
|
// Rules that start within this many days are worth activating now.
|
|
@@ -57,9 +58,6 @@ export function activationRules(card, today) {
|
|
|
57
58
|
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
58
59
|
.map((r) => r.id);
|
|
59
60
|
}
|
|
60
|
-
// Bank of America Preferred Rewards tiers: a person holds one at a time, so
|
|
61
|
-
// with one saved the others aren't missing.
|
|
62
|
-
const TIER = /^bofa-rewards-/;
|
|
63
61
|
export function membershipsUsed(card) {
|
|
64
62
|
return [
|
|
65
63
|
...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
|
|
@@ -70,13 +68,13 @@ export function membershipsUsed(card) {
|
|
|
70
68
|
export function membershipsFor(card) {
|
|
71
69
|
return [
|
|
72
70
|
...membershipsUsed(card),
|
|
73
|
-
...(card.requires_membership ? [card.requires_membership
|
|
71
|
+
...(card.requires_membership ? [card.requires_membership] : []),
|
|
74
72
|
];
|
|
75
73
|
}
|
|
76
74
|
export function knownMemberships(catalog) {
|
|
77
|
-
return
|
|
75
|
+
return catalog.memberships.map((m) => m.id).sort();
|
|
78
76
|
}
|
|
79
|
-
export function cardInfo(card, wallet, today) {
|
|
77
|
+
export function cardInfo(catalog, card, wallet, today) {
|
|
80
78
|
return {
|
|
81
79
|
id: card.id,
|
|
82
80
|
name: card.name,
|
|
@@ -95,13 +93,13 @@ export function cardInfo(card, wallet, today) {
|
|
|
95
93
|
default: c.default ?? null,
|
|
96
94
|
})),
|
|
97
95
|
memberships: membershipsUsed(card),
|
|
98
|
-
requiresMembership: card.requires_membership
|
|
99
|
-
?
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
96
|
+
requiresMembership: card.requires_membership === undefined
|
|
97
|
+
? null
|
|
98
|
+
: {
|
|
99
|
+
id: card.requires_membership,
|
|
100
|
+
name: membershipName(catalog, card.requires_membership),
|
|
101
|
+
annualFee: membership(catalog, card.requires_membership)?.annual_fee ?? 0,
|
|
102
|
+
},
|
|
105
103
|
activation: activationRules(card, today),
|
|
106
104
|
needsAnniversary: needsAnniversary(card),
|
|
107
105
|
};
|
|
@@ -129,7 +127,7 @@ export function searchCards(catalog, query, wallet, today) {
|
|
|
129
127
|
.sort(byShortestId)
|
|
130
128
|
.slice(0, 5);
|
|
131
129
|
return {
|
|
132
|
-
cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
|
|
130
|
+
cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
|
|
133
131
|
exact,
|
|
134
132
|
};
|
|
135
133
|
}
|
|
@@ -155,8 +153,8 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
155
153
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
156
154
|
});
|
|
157
155
|
}
|
|
158
|
-
|
|
159
|
-
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.
|
|
156
|
+
// With one tier of a group saved, the others aren't missing.
|
|
157
|
+
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
|
|
160
158
|
if (missing.length > 0) {
|
|
161
159
|
notes.push({
|
|
162
160
|
kind: 'membership',
|
package/dist/engine/simulate.js
CHANGED
|
@@ -5,9 +5,9 @@ import { membershipCosts, membershipNote, paidMemberships, } from "./memberships
|
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { rotationGap } from "./rotation.js";
|
|
8
|
-
import { roundCents } from "./text.js";
|
|
8
|
+
import { money, roundCents } from "./text.js";
|
|
9
9
|
import { cardValuation } from "./valuation.js";
|
|
10
|
-
|
|
10
|
+
import { EPSILON } from "./compare.js";
|
|
11
11
|
const MONTHS = 12;
|
|
12
12
|
const SHORTLIST = 8; // best single cards considered for pairs
|
|
13
13
|
const PER_LINE = 2; // plus the best cards for each spending line
|
|
@@ -129,7 +129,7 @@ held = []) {
|
|
|
129
129
|
heldCards: held,
|
|
130
130
|
merchantIds: lines.map((l) => l.merchant.merchantId),
|
|
131
131
|
});
|
|
132
|
-
const memberships = membershipCosts(cards.map((c) => c.card), paid);
|
|
132
|
+
const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
|
|
133
133
|
const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
|
|
134
134
|
const notes = [];
|
|
135
135
|
for (const m of memberships)
|
|
@@ -146,7 +146,7 @@ held = []) {
|
|
|
146
146
|
else if (!held.includes(card.id) &&
|
|
147
147
|
card.first_year_fee !== undefined &&
|
|
148
148
|
card.first_year_fee !== card.annual_fee)
|
|
149
|
-
notes.push(`${card.name}'s fee is
|
|
149
|
+
notes.push(`${card.name}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
150
150
|
}
|
|
151
151
|
return {
|
|
152
152
|
cards: wallet.cards.map((h) => ({
|
|
@@ -342,4 +342,4 @@ export function spendLineFor(catalog, what, monthly) {
|
|
|
342
342
|
export function typicalSpendLines(catalog) {
|
|
343
343
|
return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
|
|
344
344
|
}
|
|
345
|
-
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what}
|
|
345
|
+
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
package/dist/engine/text.js
CHANGED
|
@@ -4,15 +4,22 @@ export const pct = (n) => `${num(n)}%`;
|
|
|
4
4
|
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
5
|
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
6
|
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
month: 'short',
|
|
10
|
-
day: 'numeric',
|
|
11
|
-
year: 'numeric',
|
|
7
|
+
const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
8
|
+
...options,
|
|
12
9
|
timeZone: 'UTC',
|
|
13
10
|
});
|
|
14
|
-
//
|
|
15
|
-
|
|
11
|
+
// From YYYY-MM-DD, the same in every time zone: "Dec 31, 2026",
|
|
12
|
+
// "December 31, 2026", "December 2026".
|
|
13
|
+
export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
14
|
+
export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
|
|
15
|
+
export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
|
|
16
|
+
// Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
|
|
17
|
+
export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
18
|
+
// Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
|
|
19
|
+
export const wholeDollars = (n) => money(Math.round(n));
|
|
20
|
+
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
|
|
21
|
+
// loss reads −$618.
|
|
22
|
+
export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
16
23
|
export function feeText(fees) {
|
|
17
24
|
const { annualFee, firstYearFee } = fees;
|
|
18
25
|
if (annualFee === null)
|
|
@@ -69,13 +76,13 @@ export const NETWORK_NAMES = {
|
|
|
69
76
|
};
|
|
70
77
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
71
78
|
// list's: those lists use commas only, with those names last.
|
|
72
|
-
export const listed = (parts) => {
|
|
79
|
+
export const listed = (parts, joiner = 'and') => {
|
|
73
80
|
const anded = parts.filter((p) => p.includes(' and '));
|
|
74
81
|
if (anded.length > 0)
|
|
75
82
|
return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
|
|
76
83
|
return parts.length <= 2
|
|
77
|
-
? parts.join(
|
|
78
|
-
: `${parts.slice(0, -1).join(', ')}
|
|
84
|
+
? parts.join(` ${joiner} `)
|
|
85
|
+
: `${parts.slice(0, -1).join(', ')} ${joiner} ${parts.at(-1) ?? ''}`;
|
|
79
86
|
};
|
|
80
87
|
const NUMBERS = {
|
|
81
88
|
one: '1',
|
|
@@ -89,16 +96,23 @@ const NUMBERS = {
|
|
|
89
96
|
export function choiceName(id) {
|
|
90
97
|
return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
|
|
91
98
|
}
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
// Period words in one place: a cap of $1,500 "a quarter", a "quarterly" cap,
|
|
100
|
+
// a category picked "each quarter".
|
|
101
|
+
export const CAP_PERIODS = {
|
|
102
|
+
month: 'a month',
|
|
103
|
+
quarter: 'a quarter',
|
|
104
|
+
calendar_year: 'a calendar year',
|
|
105
|
+
cardmember_year: 'a cardmember year',
|
|
106
|
+
};
|
|
107
|
+
export const CAP_ADJECTIVES = {
|
|
108
|
+
month: 'monthly',
|
|
109
|
+
quarter: 'quarterly',
|
|
110
|
+
calendar_year: 'calendar-year',
|
|
111
|
+
cardmember_year: 'cardmember-year',
|
|
112
|
+
};
|
|
113
|
+
export const PICK_PERIODS = {
|
|
114
|
+
month: 'each month',
|
|
115
|
+
quarter: 'each quarter',
|
|
116
|
+
calendar_year: 'each year',
|
|
117
|
+
until_changed: 'until you change it',
|
|
103
118
|
};
|
|
104
|
-
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
package/dist/engine/valuation.js
CHANGED
|
@@ -251,3 +251,20 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
251
251
|
notes: [],
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
+
// Point values in cents per point, by currency id; null goes back to
|
|
255
|
+
// earnrate's default for that currency.
|
|
256
|
+
export function withValuations(catalog, wallet, changes) {
|
|
257
|
+
const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
|
|
258
|
+
if (unknown.length > 0)
|
|
259
|
+
return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${list(catalog.currencies.map((c) => c.id))}.`);
|
|
260
|
+
const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
|
|
261
|
+
if (odd.length > 0)
|
|
262
|
+
return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
|
|
263
|
+
const valuations = Object.fromEntries([
|
|
264
|
+
...Object.entries(wallet.valuations).filter(([id]) => !(id in changes)),
|
|
265
|
+
...Object.entries(changes).filter(([, cents]) => cents !== null),
|
|
266
|
+
]);
|
|
267
|
+
return { ok: true, wallet: { ...wallet, valuations }, fields: {}, notes: [] };
|
|
268
|
+
}
|
|
269
|
+
// Higher than any program's points are worth, even transferred.
|
|
270
|
+
const MAX_CENTS = 5;
|
|
@@ -5,16 +5,19 @@ import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { activationRules } from "./setup.js";
|
|
7
7
|
import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
|
|
8
|
-
import { cardValuation } from "./valuation.js";
|
|
8
|
+
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
9
9
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
10
|
-
// lower case and plural; brand names keep theirs.
|
|
10
|
+
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
11
11
|
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
12
12
|
function merchantName(catalog, id) {
|
|
13
13
|
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
const merchant = all.find((m) => m.id === id);
|
|
15
|
+
const name = merchant?.name ?? words(id);
|
|
16
|
+
return merchant?.kind === 'booking'
|
|
17
|
+
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
18
|
+
: name;
|
|
16
19
|
}
|
|
17
|
-
function optionName(catalog, card, choiceId, option) {
|
|
20
|
+
export function optionName(catalog, card, choiceId, option) {
|
|
18
21
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
19
22
|
if (!o)
|
|
20
23
|
return words(option);
|
|
@@ -148,6 +151,13 @@ function capSummaries(catalog, card, held, today) {
|
|
|
148
151
|
};
|
|
149
152
|
});
|
|
150
153
|
}
|
|
154
|
+
// "into a SoFi account", for a wallet that values points by a redemption.
|
|
155
|
+
function redemptionOf(catalog, currency, v) {
|
|
156
|
+
if (typeof v !== 'object' || v.redemption === undefined)
|
|
157
|
+
return null;
|
|
158
|
+
const names = catalog.currencies.find((c) => c.id === currency)?.redemption_names;
|
|
159
|
+
return names?.[v.redemption] ?? `as ${words(v.redemption)}`;
|
|
160
|
+
}
|
|
151
161
|
export function walletSummary(catalog, wallet, today) {
|
|
152
162
|
const cards = [];
|
|
153
163
|
for (const held of wallet.cards) {
|
|
@@ -194,5 +204,13 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
194
204
|
anniversary: held.anniversary ?? null,
|
|
195
205
|
});
|
|
196
206
|
}
|
|
197
|
-
|
|
207
|
+
const pointValues = Object.keys(wallet.valuations)
|
|
208
|
+
.filter((id) => catalog.currencies.some((c) => c.id === id))
|
|
209
|
+
.map((id) => ({
|
|
210
|
+
currency: id,
|
|
211
|
+
name: currencyLabel(catalog, id),
|
|
212
|
+
centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
|
|
213
|
+
redemption: redemptionOf(catalog, id, wallet.valuations[id]),
|
|
214
|
+
}));
|
|
215
|
+
return { memberships: wallet.memberships, pointValues, started: null, cards };
|
|
198
216
|
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -13,22 +13,11 @@ const ISSUERS = {
|
|
|
13
13
|
usbank: 'U.S. Bank',
|
|
14
14
|
'wells-fargo': 'Wells Fargo',
|
|
15
15
|
};
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
calendar_year: 'each year',
|
|
20
|
-
until_changed: 'until you change it',
|
|
21
|
-
};
|
|
22
|
-
export const CAP_PERIODS = {
|
|
23
|
-
month: 'a month',
|
|
24
|
-
quarter: 'a quarter',
|
|
25
|
-
calendar_year: 'a year',
|
|
26
|
-
cardmember_year: 'a card year',
|
|
27
|
-
};
|
|
28
|
-
export { MEMBERSHIPS, membershipName, words } from "../engine/text.js";
|
|
29
|
-
import { membershipName, words } from "../engine/text.js";
|
|
16
|
+
export { CAP_PERIODS, PICK_PERIODS, words } from "../engine/text.js";
|
|
17
|
+
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
18
|
+
import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
30
19
|
// Whole dollars, for fees and caps.
|
|
31
|
-
export const usd =
|
|
20
|
+
export const usd = wholeDollars;
|
|
32
21
|
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
33
22
|
export function cardText(catalog) {
|
|
34
23
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
@@ -43,7 +32,7 @@ export function cardText(catalog) {
|
|
|
43
32
|
function feeLabel(id) {
|
|
44
33
|
const card = cards.get(id);
|
|
45
34
|
const m = card?.requires_membership;
|
|
46
|
-
const needs = m ? `; needs ${
|
|
35
|
+
const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
|
|
47
36
|
const fee = card?.annual_fee;
|
|
48
37
|
if (fee === undefined)
|
|
49
38
|
return 'Annual fee not on file';
|
|
@@ -62,12 +51,19 @@ export function cardText(catalog) {
|
|
|
62
51
|
if (r.activation !== null)
|
|
63
52
|
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
64
53
|
if (r.membership !== null)
|
|
65
|
-
lines.push(`With ${membershipName(r.membership)}`);
|
|
54
|
+
lines.push(`With ${membershipName(catalog, r.membership)}`);
|
|
66
55
|
if (until !== null)
|
|
67
56
|
lines.push(`This rate runs through ${until}`);
|
|
68
57
|
if (r.cap)
|
|
69
|
-
lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]
|
|
58
|
+
lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
|
|
70
59
|
return lines;
|
|
71
60
|
}
|
|
72
|
-
|
|
61
|
+
const memberName = (id) => membershipName(catalog, id);
|
|
62
|
+
return {
|
|
63
|
+
cards,
|
|
64
|
+
shortName,
|
|
65
|
+
feeLabel,
|
|
66
|
+
requirementLines,
|
|
67
|
+
membershipName: memberName,
|
|
68
|
+
};
|
|
73
69
|
}
|
package/dist/format/cards.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { NETWORK_NAMES } from "../engine/text.js";
|
|
1
|
+
import { money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
2
|
export function formatCardInfo(card) {
|
|
3
3
|
const fee = card.annualFee === null
|
|
4
4
|
? ''
|
|
5
5
|
: card.annualFee === 0
|
|
6
6
|
? ', no annual fee'
|
|
7
|
-
: `,
|
|
7
|
+
: `, ${money(card.annualFee)} a year`;
|
|
8
8
|
const needs = [
|
|
9
9
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
10
10
|
...(card.requiresMembership
|
|
11
11
|
? [
|
|
12
|
-
`needs ${card.requiresMembership.name} (
|
|
12
|
+
`needs ${card.requiresMembership.name} (${money(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
|
|
13
13
|
]
|
|
14
14
|
: []),
|
|
15
15
|
...(card.memberships.length > 0
|