earnrate 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/data/CHANGELOG.md +79 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +23 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +50 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +19 -2
- package/data/cards/chase-ihg-one-rewards.yaml +15 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +17 -4
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +6 -9
- package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +16 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +23 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/issuers.yaml +32 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +11 -6
- package/dist/cli/explore.js +17 -23
- package/dist/cli/import.js +10 -10
- package/dist/cli/offers.js +19 -8
- package/dist/cli/run.js +31 -20
- package/dist/cli/wallet.js +50 -5
- package/dist/data/file-store.js +9 -7
- package/dist/data/integrity.js +22 -2
- package/dist/data/load.js +13 -2
- package/dist/data/store.js +13 -22
- package/dist/data/wallet-edit.js +108 -8
- package/dist/engine/cheat-sheet.js +15 -19
- package/dist/engine/compare.js +10 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +13 -56
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +18 -5
- package/dist/engine/periods.js +18 -0
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +23 -37
- package/dist/engine/setup.js +28 -40
- package/dist/engine/simulate.js +11 -10
- package/dist/engine/text.js +49 -23
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +26 -11
- package/dist/engine/wallet-summary.js +85 -31
- package/dist/format/card-text.js +19 -36
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +15 -16
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +6 -6
- package/dist/format/wallet.js +5 -2
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +61 -13
- package/dist/mcp/inputs.js +16 -7
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/server.js +8 -1
- package/dist/mcp/tools/account.js +45 -17
- package/dist/mcp/tools/answers.js +84 -29
- package/dist/mcp/tools/wallet.js +109 -22
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +8 -12
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/engine/simulate.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { categoryResolution, findCategory, resolvePlace, } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
|
-
import {
|
|
3
|
+
import { idealHolding, matchesFilters, } from "./explore.js";
|
|
4
4
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
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, HALF_CENT } 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
|
|
@@ -16,7 +16,7 @@ export function resolveSpendLines(catalog, lines) {
|
|
|
16
16
|
.filter((l) => l.monthly > 0)
|
|
17
17
|
.map((l) => {
|
|
18
18
|
const merchant = l.merchant !== null
|
|
19
|
-
?
|
|
19
|
+
? resolvePlace(catalog, l.merchant, null)
|
|
20
20
|
: categoryResolution(catalog, l.category ?? 'other', null);
|
|
21
21
|
return { label: merchant.resolvedName, monthly: l.monthly, merchant };
|
|
22
22
|
});
|
|
@@ -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) => ({
|
|
@@ -224,7 +224,7 @@ function foldTies(pairs) {
|
|
|
224
224
|
const folded = [];
|
|
225
225
|
for (const pair of pairs) {
|
|
226
226
|
const [a, b] = pair.cards;
|
|
227
|
-
const twin = folded.find((f) => Math.abs(f.net - pair.net) <
|
|
227
|
+
const twin = folded.find((f) => Math.abs(f.net - pair.net) < HALF_CENT &&
|
|
228
228
|
costs(f) === costs(pair) &&
|
|
229
229
|
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
230
230
|
if (!twin) {
|
|
@@ -339,7 +339,8 @@ export function spendLineFor(catalog, what, monthly) {
|
|
|
339
339
|
const category = findCategory(catalog, what);
|
|
340
340
|
return { merchant: category === null ? what : null, category, monthly };
|
|
341
341
|
}
|
|
342
|
+
export const typicalSpend = (catalog) => TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly));
|
|
342
343
|
export function typicalSpendLines(catalog) {
|
|
343
|
-
return resolveSpendLines(catalog,
|
|
344
|
+
return resolveSpendLines(catalog, typicalSpend(catalog));
|
|
344
345
|
}
|
|
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}
|
|
346
|
+
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
|
@@ -2,17 +2,36 @@ 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
|
+
// Ids for a message: "amex-gold, citi-double-cash".
|
|
6
|
+
export const idList = (ids) => ids.join(', ');
|
|
7
|
+
export const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
8
|
+
// "usbank.com" from "https://www.usbank.com/credit-cards/...".
|
|
9
|
+
export function hostname(url) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return url;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
5
17
|
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
18
|
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
month: 'short',
|
|
10
|
-
day: 'numeric',
|
|
11
|
-
year: 'numeric',
|
|
19
|
+
const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
20
|
+
...options,
|
|
12
21
|
timeZone: 'UTC',
|
|
13
22
|
});
|
|
14
|
-
//
|
|
15
|
-
|
|
23
|
+
// From YYYY-MM-DD, the same in every time zone: "Dec 31, 2026",
|
|
24
|
+
// "December 31, 2026", "December 2026".
|
|
25
|
+
export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
26
|
+
export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
|
|
27
|
+
export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
|
|
28
|
+
// Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
|
|
29
|
+
export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
30
|
+
// Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
|
|
31
|
+
export const wholeDollars = (n) => money(Math.round(n));
|
|
32
|
+
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
|
|
33
|
+
// loss reads −$618.
|
|
34
|
+
export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
16
35
|
export function feeText(fees) {
|
|
17
36
|
const { annualFee, firstYearFee } = fees;
|
|
18
37
|
if (annualFee === null)
|
|
@@ -40,7 +59,7 @@ export function categoryPhrase(catalog, id) {
|
|
|
40
59
|
}
|
|
41
60
|
export function earnText(catalog, rate, currency) {
|
|
42
61
|
return currency === 'cash'
|
|
43
|
-
? `${
|
|
62
|
+
? `${pct(rate)} cash back`
|
|
44
63
|
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
45
64
|
}
|
|
46
65
|
export function codesAs(catalog, merchant) {
|
|
@@ -69,13 +88,13 @@ export const NETWORK_NAMES = {
|
|
|
69
88
|
};
|
|
70
89
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
71
90
|
// list's: those lists use commas only, with those names last.
|
|
72
|
-
export const listed = (parts) => {
|
|
91
|
+
export const listed = (parts, joiner = 'and') => {
|
|
73
92
|
const anded = parts.filter((p) => p.includes(' and '));
|
|
74
93
|
if (anded.length > 0)
|
|
75
94
|
return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
|
|
76
95
|
return parts.length <= 2
|
|
77
|
-
? parts.join(
|
|
78
|
-
: `${parts.slice(0, -1).join(', ')}
|
|
96
|
+
? parts.join(` ${joiner} `)
|
|
97
|
+
: `${parts.slice(0, -1).join(', ')} ${joiner} ${parts.at(-1) ?? ''}`;
|
|
79
98
|
};
|
|
80
99
|
const NUMBERS = {
|
|
81
100
|
one: '1',
|
|
@@ -89,16 +108,23 @@ const NUMBERS = {
|
|
|
89
108
|
export function choiceName(id) {
|
|
90
109
|
return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
|
|
91
110
|
}
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
111
|
+
// Period words in one place: a cap of $1,500 "a quarter", a "quarterly" cap,
|
|
112
|
+
// a category picked "each quarter".
|
|
113
|
+
export const CAP_PERIODS = {
|
|
114
|
+
month: 'a month',
|
|
115
|
+
quarter: 'a quarter',
|
|
116
|
+
calendar_year: 'a calendar year',
|
|
117
|
+
cardmember_year: 'a cardmember year',
|
|
118
|
+
};
|
|
119
|
+
export const CAP_ADJECTIVES = {
|
|
120
|
+
month: 'monthly',
|
|
121
|
+
quarter: 'quarterly',
|
|
122
|
+
calendar_year: 'calendar-year',
|
|
123
|
+
cardmember_year: 'cardmember-year',
|
|
124
|
+
};
|
|
125
|
+
export const PICK_PERIODS = {
|
|
126
|
+
month: 'each month',
|
|
127
|
+
quarter: 'each quarter',
|
|
128
|
+
calendar_year: 'each year',
|
|
129
|
+
until_changed: 'until you change it',
|
|
103
130
|
};
|
|
104
|
-
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
package/dist/engine/valuation.js
CHANGED
|
@@ -3,15 +3,15 @@ import { addMonths } from "./bonus.js";
|
|
|
3
3
|
import { capByKey } from "./evaluate.js";
|
|
4
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
5
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
6
|
+
import { idList } from "./text.js";
|
|
6
7
|
const failed = (error) => ({ ok: false, error });
|
|
7
|
-
const list = (ids) => ids.join(', ');
|
|
8
8
|
function findCatalogCard(catalog, id, today) {
|
|
9
9
|
const card = catalog.cards.find((c) => c.id === id);
|
|
10
10
|
if (card)
|
|
11
11
|
return card;
|
|
12
12
|
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
13
13
|
return near.length > 0
|
|
14
|
-
? `No card '${id}' in earnrate. Closest: ${
|
|
14
|
+
? `No card '${id}' in earnrate. Closest: ${idList(near)}.`
|
|
15
15
|
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
16
16
|
}
|
|
17
17
|
function isAnniversary(text) {
|
|
@@ -51,7 +51,7 @@ export function cardFields(card, held, input, today) {
|
|
|
51
51
|
for (const id of input.activated) {
|
|
52
52
|
if (!needing.includes(id)) {
|
|
53
53
|
problems.push(needing.length > 0
|
|
54
|
-
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${
|
|
54
|
+
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${idList(needing)}.`
|
|
55
55
|
: `${card.id} has no rules that need activating.`);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -62,14 +62,14 @@ export function cardFields(card, held, input, today) {
|
|
|
62
62
|
const choice = card.choices.find((c) => c.id === choiceId);
|
|
63
63
|
if (!choice) {
|
|
64
64
|
problems.push(card.choices.length > 0
|
|
65
|
-
? `${card.id} has no '${choiceId}' pick. Its picks are: ${
|
|
65
|
+
? `${card.id} has no '${choiceId}' pick. Its picks are: ${idList(card.choices.map((c) => c.id))}.`
|
|
66
66
|
: `${card.id} has no categories to pick.`);
|
|
67
67
|
continue;
|
|
68
68
|
}
|
|
69
69
|
const options = Object.keys(choice.options);
|
|
70
70
|
for (const p of picks) {
|
|
71
71
|
if (!options.includes(p)) {
|
|
72
|
-
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${
|
|
72
|
+
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${idList(options)}.`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
if (picks.length === 0 || picks.length > choice.pick) {
|
|
@@ -101,7 +101,7 @@ export function cardFields(card, held, input, today) {
|
|
|
101
101
|
.map((r) => r.id),
|
|
102
102
|
];
|
|
103
103
|
problems.push(keys.length > 0
|
|
104
|
-
? `${card.id} has no cap '${key}'. Its caps are: ${
|
|
104
|
+
? `${card.id} has no cap '${key}'. Its caps are: ${idList(keys)}.`
|
|
105
105
|
: `${card.id} has no spending caps.`);
|
|
106
106
|
continue;
|
|
107
107
|
}
|
|
@@ -206,7 +206,7 @@ export function updateInWallet(catalog, wallet, input, today) {
|
|
|
206
206
|
const held = wallet.cards.find((c) => c.id === input.id);
|
|
207
207
|
const card = catalog.cards.find((c) => c.id === input.id);
|
|
208
208
|
if (!held || !card) {
|
|
209
|
-
return failed(`${input.id} isn't in the wallet. Cards: ${
|
|
209
|
+
return failed(`${input.id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
|
|
210
210
|
}
|
|
211
211
|
const fields = cardFields(card, held, input, today);
|
|
212
212
|
if (typeof fields === 'string')
|
|
@@ -227,10 +227,8 @@ export function updateInWallet(catalog, wallet, input, today) {
|
|
|
227
227
|
}
|
|
228
228
|
export function removeFromWallet(wallet, id) {
|
|
229
229
|
if (!wallet.cards.some((c) => c.id === id)) {
|
|
230
|
-
return failed(`${id} isn't in the wallet. Cards: ${
|
|
230
|
+
return failed(`${id} isn't in the wallet. Cards: ${idList(wallet.cards.map((c) => c.id))}.`);
|
|
231
231
|
}
|
|
232
|
-
// Removing the last card leaves an empty wallet, which each store keeps
|
|
233
|
-
// in its own way (or refuses, for a wallet file).
|
|
234
232
|
return {
|
|
235
233
|
ok: true,
|
|
236
234
|
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
@@ -242,7 +240,7 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
242
240
|
const known = knownMemberships(catalog);
|
|
243
241
|
const unknown = memberships.filter((m) => !known.includes(m));
|
|
244
242
|
if (unknown.length > 0) {
|
|
245
|
-
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${
|
|
243
|
+
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${idList(known)}.`);
|
|
246
244
|
}
|
|
247
245
|
return {
|
|
248
246
|
ok: true,
|
|
@@ -251,3 +249,20 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
251
249
|
notes: [],
|
|
252
250
|
};
|
|
253
251
|
}
|
|
252
|
+
// Point values in cents per point, by currency id; null goes back to
|
|
253
|
+
// earnrate's default for that currency.
|
|
254
|
+
export function withValuations(catalog, wallet, changes) {
|
|
255
|
+
const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
|
|
256
|
+
if (unknown.length > 0)
|
|
257
|
+
return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${idList(catalog.currencies.map((c) => c.id))}.`);
|
|
258
|
+
const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
|
|
259
|
+
if (odd.length > 0)
|
|
260
|
+
return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
|
|
261
|
+
const valuations = Object.fromEntries([
|
|
262
|
+
...Object.entries(wallet.valuations).filter(([id]) => !(id in changes)),
|
|
263
|
+
...Object.entries(changes).filter(([, cents]) => cents !== null),
|
|
264
|
+
]);
|
|
265
|
+
return { ok: true, wallet: { ...wallet, valuations }, fields: {}, notes: [] };
|
|
266
|
+
}
|
|
267
|
+
// Higher than any program's points are worth, even transferred.
|
|
268
|
+
const MAX_CENTS = 5;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
+
import { findMerchant } from "../merchants/resolve.js";
|
|
1
2
|
import { bonusStatus } from "./bonus.js";
|
|
2
3
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
|
-
import {
|
|
4
|
+
import { membershipName } from "./memberships.js";
|
|
5
|
+
import { daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
4
6
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
7
|
import { rotationGap } from "./rotation.js";
|
|
6
8
|
import { activationRules } from "./setup.js";
|
|
7
|
-
import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
|
|
8
|
-
import { cardValuation } from "./valuation.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
|
|
10
|
+
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
9
11
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
10
|
-
// lower case and plural; brand names keep theirs.
|
|
12
|
+
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
11
13
|
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
12
14
|
function merchantName(catalog, id) {
|
|
13
|
-
const
|
|
14
|
-
const name =
|
|
15
|
-
return
|
|
15
|
+
const merchant = findMerchant(catalog, id);
|
|
16
|
+
const name = merchant?.name ?? words(id);
|
|
17
|
+
return merchant?.kind === 'booking'
|
|
18
|
+
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
19
|
+
: name;
|
|
16
20
|
}
|
|
17
|
-
function optionName(catalog, card, choiceId, option) {
|
|
21
|
+
export function optionName(catalog, card, choiceId, option) {
|
|
18
22
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
19
23
|
if (!o)
|
|
20
24
|
return words(option);
|
|
@@ -30,30 +34,22 @@ function optionName(catalog, card, choiceId, option) {
|
|
|
30
34
|
function ruleParts(catalog, rule) {
|
|
31
35
|
if (rule.match.choice !== undefined)
|
|
32
36
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
37
|
+
const merchants = rule.match.merchants ?? [];
|
|
38
|
+
// A long list of one kind of store reads as "select streaming services".
|
|
39
|
+
const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
|
|
40
|
+
const [kind] = kinds;
|
|
41
|
+
const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
|
|
42
|
+
? [`select ${categoryPhrase(catalog, kind)}`]
|
|
43
|
+
: merchants.map((m) => merchantName(catalog, m));
|
|
33
44
|
const parts = [
|
|
34
45
|
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
35
|
-
...
|
|
46
|
+
...merchantParts,
|
|
36
47
|
];
|
|
37
48
|
return parts.length > 0 ? parts : [words(rule.id)];
|
|
38
49
|
}
|
|
39
50
|
function ruleWhat(catalog, rule) {
|
|
40
51
|
return listed(ruleParts(catalog, rule));
|
|
41
52
|
}
|
|
42
|
-
function periodEnd(period) {
|
|
43
|
-
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
44
|
-
if (quarter) {
|
|
45
|
-
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
46
|
-
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
47
|
-
}
|
|
48
|
-
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
49
|
-
if (month) {
|
|
50
|
-
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
51
|
-
return last.toISOString().slice(0, 10);
|
|
52
|
-
}
|
|
53
|
-
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
54
|
-
}
|
|
55
|
-
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
56
|
-
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
57
53
|
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
58
54
|
? []
|
|
59
55
|
: [
|
|
@@ -65,9 +61,22 @@ const matchKeys = (rule) => rule.match.choice !== undefined
|
|
|
65
61
|
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
66
62
|
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
67
63
|
// a line.
|
|
64
|
+
// "first $1,500 a quarter, combined": how much of a rate's spending earns it.
|
|
65
|
+
function capLimit(card, shown, rule) {
|
|
66
|
+
if (rule.cap === undefined)
|
|
67
|
+
return '';
|
|
68
|
+
const cap = typeof rule.cap === 'string'
|
|
69
|
+
? card.caps.find((c) => c.id === rule.cap)
|
|
70
|
+
: rule.cap;
|
|
71
|
+
if (!cap)
|
|
72
|
+
return '';
|
|
73
|
+
const shared = typeof rule.cap === 'string' &&
|
|
74
|
+
shown.filter((r) => r.cap === rule.cap).length > 1;
|
|
75
|
+
return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
|
|
76
|
+
}
|
|
68
77
|
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
69
78
|
const shown = card.rules
|
|
70
|
-
.filter((r) =>
|
|
79
|
+
.filter((r) => inEffect(r, today) &&
|
|
71
80
|
r.rate > card.base_rate &&
|
|
72
81
|
(r.requires?.membership === undefined ||
|
|
73
82
|
wallet.memberships.includes(r.requires.membership)))
|
|
@@ -83,8 +92,11 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
83
92
|
if (keys.length > 0 && keys.every((k) => higher.includes(k)))
|
|
84
93
|
continue;
|
|
85
94
|
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
86
|
-
const
|
|
87
|
-
|
|
95
|
+
const key = [
|
|
96
|
+
`${earnText(catalog, r.rate, currency)} on`,
|
|
97
|
+
capLimit(card, shown, r),
|
|
98
|
+
pending ? ', once activated' : '',
|
|
99
|
+
].join('|');
|
|
88
100
|
const whats = lines.get(key) ?? [];
|
|
89
101
|
for (const part of ruleParts(catalog, r))
|
|
90
102
|
if (!whats.includes(part))
|
|
@@ -92,7 +104,11 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
92
104
|
lines.set(key, whats);
|
|
93
105
|
}
|
|
94
106
|
return [
|
|
95
|
-
...[...lines].map(([key, whats]) =>
|
|
107
|
+
...[...lines].map(([key, whats]) => {
|
|
108
|
+
const [head = '', limit = '', activate = ''] = key.split('|');
|
|
109
|
+
const after = `${limit}${activate}`;
|
|
110
|
+
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
111
|
+
}),
|
|
96
112
|
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
97
113
|
];
|
|
98
114
|
}
|
|
@@ -107,9 +123,14 @@ function toActivate(catalog, card, held, today) {
|
|
|
107
123
|
seen.add(g);
|
|
108
124
|
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
109
125
|
const first = rules[0];
|
|
126
|
+
const deadline = first?.activate_by === undefined
|
|
127
|
+
? ''
|
|
128
|
+
: `, activate by ${shortDate(first.activate_by)}`;
|
|
110
129
|
const when = first?.valid_from !== undefined && first.valid_to !== undefined
|
|
111
|
-
? ` (${first.valid_from} to ${first.valid_to})`
|
|
112
|
-
: ''
|
|
130
|
+
? ` (${shortDate(first.valid_from)} to ${shortDate(first.valid_to)}${deadline})`
|
|
131
|
+
: deadline === ''
|
|
132
|
+
? ''
|
|
133
|
+
: ` (${deadline.slice(2)})`;
|
|
113
134
|
out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
|
|
114
135
|
}
|
|
115
136
|
return out;
|
|
@@ -148,6 +169,13 @@ function capSummaries(catalog, card, held, today) {
|
|
|
148
169
|
};
|
|
149
170
|
});
|
|
150
171
|
}
|
|
172
|
+
// "into a SoFi account", for a wallet that values points by a redemption.
|
|
173
|
+
function redemptionOf(catalog, currency, v) {
|
|
174
|
+
if (typeof v !== 'object' || v.redemption === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
const names = catalog.currencies.find((c) => c.id === currency)?.redemption_names;
|
|
177
|
+
return names?.[v.redemption] ?? `as ${words(v.redemption)}`;
|
|
178
|
+
}
|
|
151
179
|
export function walletSummary(catalog, wallet, today) {
|
|
152
180
|
const cards = [];
|
|
153
181
|
for (const held of wallet.cards) {
|
|
@@ -194,5 +222,31 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
194
222
|
anniversary: held.anniversary ?? null,
|
|
195
223
|
});
|
|
196
224
|
}
|
|
197
|
-
return {
|
|
225
|
+
return {
|
|
226
|
+
memberships: wallet.memberships,
|
|
227
|
+
membershipNames: wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
228
|
+
pointValues: pointValues(catalog, wallet),
|
|
229
|
+
started: null,
|
|
230
|
+
cards,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function pointValues(catalog, wallet) {
|
|
234
|
+
return Object.keys(wallet.valuations)
|
|
235
|
+
.filter((id) => catalog.currencies.some((c) => c.id === id))
|
|
236
|
+
.map((id) => ({
|
|
237
|
+
currency: id,
|
|
238
|
+
name: currencyLabel(catalog, id),
|
|
239
|
+
centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
|
|
240
|
+
redemption: redemptionOf(catalog, id, wallet.valuations[id]),
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
// The memberships and point values a wallet holds besides its cards, in
|
|
244
|
+
// words: what replacing or deleting it would lose.
|
|
245
|
+
export function savedSettingsText(catalog, wallet) {
|
|
246
|
+
return [
|
|
247
|
+
...wallet.memberships.map((m) => membershipName(catalog, m)),
|
|
248
|
+
...pointValues(catalog, wallet).map((v) => v.redemption === null
|
|
249
|
+
? `${v.name} at ${String(v.centsPerUnit)}¢`
|
|
250
|
+
: `${v.name} redeemed ${v.redemption}`),
|
|
251
|
+
];
|
|
198
252
|
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -1,35 +1,10 @@
|
|
|
1
1
|
import { earnText } from "../engine/text.js";
|
|
2
2
|
// Card names, fees and requirements as earnrate.dev and its static search
|
|
3
3
|
// pages show them. Node-free: the site bundles it.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
chase: 'Chase',
|
|
9
|
-
citi: 'Citi',
|
|
10
|
-
column: 'Bilt',
|
|
11
|
-
sofi: 'SoFi',
|
|
12
|
-
'td-bank': 'TD Bank',
|
|
13
|
-
usbank: 'U.S. Bank',
|
|
14
|
-
'wells-fargo': 'Wells Fargo',
|
|
15
|
-
};
|
|
16
|
-
export const PICK_PERIODS = {
|
|
17
|
-
month: 'each month',
|
|
18
|
-
quarter: 'each quarter',
|
|
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";
|
|
30
|
-
// Whole dollars, for fees and caps.
|
|
31
|
-
export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
|
|
32
|
-
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
4
|
+
import { membershipName, membershipText } from "../engine/memberships.js";
|
|
5
|
+
import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
|
|
6
|
+
// An issuer's name as people know it, from data/issuers.yaml.
|
|
7
|
+
export const issuerName = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
33
8
|
export function cardText(catalog) {
|
|
34
9
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
35
10
|
function shortName(id) {
|
|
@@ -43,7 +18,7 @@ export function cardText(catalog) {
|
|
|
43
18
|
function feeLabel(id) {
|
|
44
19
|
const card = cards.get(id);
|
|
45
20
|
const m = card?.requires_membership;
|
|
46
|
-
const needs = m ? `; needs ${
|
|
21
|
+
const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
|
|
47
22
|
const fee = card?.annual_fee;
|
|
48
23
|
if (fee === undefined)
|
|
49
24
|
return 'Annual fee not on file';
|
|
@@ -51,8 +26,8 @@ export function cardText(catalog) {
|
|
|
51
26
|
return `No annual fee${needs}`;
|
|
52
27
|
const first = card?.first_year_fee;
|
|
53
28
|
if (first !== undefined && first !== fee)
|
|
54
|
-
return `${
|
|
55
|
-
return `${
|
|
29
|
+
return `${wholeDollars(fee)} a year (${first === 0 ? 'waived' : wholeDollars(first)} the first year)${needs}`;
|
|
30
|
+
return `${wholeDollars(fee)} a year${needs}`;
|
|
56
31
|
}
|
|
57
32
|
// `until` is the last day a time-limited rate applies, already formatted.
|
|
58
33
|
function requirementLines(cardId, r, until = null) {
|
|
@@ -60,14 +35,22 @@ export function cardText(catalog) {
|
|
|
60
35
|
if (r.pick)
|
|
61
36
|
lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
|
|
62
37
|
if (r.activation !== null)
|
|
63
|
-
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
38
|
+
lines.push(`Activate the bonus with ${issuerName(catalog, cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
64
39
|
if (r.membership !== null)
|
|
65
|
-
lines.push(`With ${membershipName(r.membership)}`);
|
|
40
|
+
lines.push(`With ${membershipName(catalog, r.membership)}`);
|
|
66
41
|
if (until !== null)
|
|
67
42
|
lines.push(`This rate runs through ${until}`);
|
|
68
43
|
if (r.cap)
|
|
69
|
-
lines.push(`Up to ${
|
|
44
|
+
lines.push(`Up to ${wholeDollars(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
|
|
70
45
|
return lines;
|
|
71
46
|
}
|
|
72
|
-
|
|
47
|
+
const memberName = (id) => membershipName(catalog, id);
|
|
48
|
+
return {
|
|
49
|
+
cards,
|
|
50
|
+
shortName,
|
|
51
|
+
feeLabel,
|
|
52
|
+
requirementLines,
|
|
53
|
+
membershipName: memberName,
|
|
54
|
+
issuerName: (id) => issuerName(catalog, id),
|
|
55
|
+
};
|
|
73
56
|
}
|
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
|