earnrate 0.2.3 → 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 +15 -14
- package/data/CHANGELOG.md +74 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- 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-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/chase-united-club.yaml +1 -1
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
- 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 +33 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +3 -3
- package/dist/cli/explore.js +12 -11
- package/dist/cli/import.js +13 -12
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +18 -4
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +26 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +83 -2
- package/dist/engine/cheat-sheet.js +53 -14
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +18 -20
- package/dist/engine/memberships.js +71 -0
- package/dist/engine/offers.js +37 -5
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +78 -44
- package/dist/engine/setup.js +22 -5
- package/dist/engine/simulate.js +36 -14
- package/dist/engine/text.js +67 -2
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +42 -4
- package/dist/engine/wallet-summary.js +83 -37
- package/dist/format/card-text.js +19 -29
- package/dist/format/cards.js +8 -9
- package/dist/format/explore.js +22 -14
- package/dist/format/offers.js +12 -21
- package/dist/format/recommendation.js +16 -10
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +16 -20
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +17 -8
- package/dist/mcp/schemas.js +21 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +28 -10
- package/dist/mcp/tools/answers.js +51 -18
- package/dist/mcp/tools/wallet.js +106 -11
- package/dist/schema/card.js +7 -0
- 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/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
|
@@ -4,49 +4,43 @@ import { daysBetween } from "./periods.js";
|
|
|
4
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { activationRules } from "./setup.js";
|
|
7
|
-
import { categoryPhrase, earnText } from "./text.js";
|
|
8
|
-
import { cardValuation } from "./valuation.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
three: '3',
|
|
13
|
-
four: '4',
|
|
14
|
-
five: '5',
|
|
15
|
-
six: '6',
|
|
16
|
-
};
|
|
17
|
-
// "five-percent" reads "5%"; other ids read as words.
|
|
18
|
-
export function choiceName(id) {
|
|
19
|
-
return id
|
|
20
|
-
.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
|
|
21
|
-
.replace(/[-_.]/g, ' ');
|
|
22
|
-
}
|
|
23
|
-
const listed = (parts) => parts.length <= 2
|
|
24
|
-
? parts.join(' and ')
|
|
25
|
-
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
|
|
7
|
+
import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
|
|
8
|
+
import { cardValuation, currencyValuation } from "./valuation.js";
|
|
9
|
+
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
10
|
+
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
11
|
+
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
26
12
|
function merchantName(catalog, id) {
|
|
27
13
|
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
28
|
-
|
|
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;
|
|
29
19
|
}
|
|
30
|
-
function optionName(catalog, card, choiceId, option) {
|
|
20
|
+
export function optionName(catalog, card, choiceId, option) {
|
|
31
21
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
32
22
|
if (!o)
|
|
33
|
-
return option
|
|
23
|
+
return words(option);
|
|
34
24
|
const parts = [
|
|
35
25
|
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
36
26
|
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
37
27
|
];
|
|
38
28
|
if (o.online)
|
|
39
29
|
parts.push('online shopping');
|
|
40
|
-
return parts.length > 0 ? listed(parts) : option
|
|
30
|
+
return parts.length > 0 ? listed(parts) : words(option);
|
|
41
31
|
}
|
|
42
|
-
|
|
32
|
+
// What a rule covers, as a list of phrases.
|
|
33
|
+
function ruleParts(catalog, rule) {
|
|
43
34
|
if (rule.match.choice !== undefined)
|
|
44
|
-
return `your ${choiceName(rule.match.choice)} picks
|
|
35
|
+
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
45
36
|
const parts = [
|
|
46
37
|
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
47
38
|
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
48
39
|
];
|
|
49
|
-
return parts.length > 0 ?
|
|
40
|
+
return parts.length > 0 ? parts : [words(rule.id)];
|
|
41
|
+
}
|
|
42
|
+
function ruleWhat(catalog, rule) {
|
|
43
|
+
return listed(ruleParts(catalog, rule));
|
|
50
44
|
}
|
|
51
45
|
function periodEnd(period) {
|
|
52
46
|
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
@@ -63,14 +57,45 @@ function periodEnd(period) {
|
|
|
63
57
|
}
|
|
64
58
|
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
65
59
|
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
|
|
60
|
+
const matchKeys = (rule) => rule.match.choice !== undefined
|
|
61
|
+
? []
|
|
62
|
+
: [
|
|
63
|
+
...(rule.match.categories ?? []).map((c) => `category:${c}`),
|
|
64
|
+
...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
|
|
65
|
+
];
|
|
66
|
+
// Every bonus rate the cardholder gets now, best first, so none reads as the
|
|
67
|
+
// base rate. Rates behind a membership the wallet lacks are left out, and so
|
|
68
|
+
// is a lower tier that an uncapped, always-on higher rate fully covers (Prime
|
|
69
|
+
// Visa's 3% without Prime, once Prime is held). Rules at the same rate share
|
|
70
|
+
// a line.
|
|
71
|
+
function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
72
|
+
const shown = card.rules
|
|
73
|
+
.filter((r) => applies(r, today) &&
|
|
74
|
+
r.rate > card.base_rate &&
|
|
75
|
+
(r.requires?.membership === undefined ||
|
|
76
|
+
wallet.memberships.includes(r.requires.membership)))
|
|
77
|
+
.sort((a, b) => b.rate - a.rate);
|
|
78
|
+
const lines = new Map();
|
|
79
|
+
for (const r of shown) {
|
|
80
|
+
const keys = matchKeys(r);
|
|
81
|
+
const higher = shown
|
|
82
|
+
.filter((h) => h.rate > r.rate &&
|
|
83
|
+
h.cap === undefined &&
|
|
84
|
+
(!h.requires?.activation || isActivated(card, held, h.id)))
|
|
85
|
+
.flatMap(matchKeys);
|
|
86
|
+
if (keys.length > 0 && keys.every((k) => higher.includes(k)))
|
|
87
|
+
continue;
|
|
88
|
+
const pending = r.requires?.activation && !isActivated(card, held, r.id);
|
|
89
|
+
const head = `${earnText(catalog, r.rate, currency)} on`;
|
|
90
|
+
const key = pending ? `${head}|activate` : head;
|
|
91
|
+
const whats = lines.get(key) ?? [];
|
|
92
|
+
for (const part of ruleParts(catalog, r))
|
|
93
|
+
if (!whats.includes(part))
|
|
94
|
+
whats.push(part);
|
|
95
|
+
lines.set(key, whats);
|
|
96
|
+
}
|
|
72
97
|
return [
|
|
73
|
-
...
|
|
98
|
+
...[...lines].map(([key, whats]) => `${key.replace('|activate', '')} ${listed(whats)}${key.endsWith('|activate') ? ' (once activated)' : ''}`),
|
|
74
99
|
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
75
100
|
];
|
|
76
101
|
}
|
|
@@ -113,7 +138,7 @@ function capSummaries(catalog, card, held, today) {
|
|
|
113
138
|
const label = shared?.rotating
|
|
114
139
|
? 'the rotating bonus categories'
|
|
115
140
|
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
|
|
116
|
-
key
|
|
141
|
+
words(key);
|
|
117
142
|
return {
|
|
118
143
|
key,
|
|
119
144
|
label,
|
|
@@ -126,6 +151,13 @@ function capSummaries(catalog, card, held, today) {
|
|
|
126
151
|
};
|
|
127
152
|
});
|
|
128
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
|
+
}
|
|
129
161
|
export function walletSummary(catalog, wallet, today) {
|
|
130
162
|
const cards = [];
|
|
131
163
|
for (const held of wallet.cards) {
|
|
@@ -142,6 +174,9 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
142
174
|
onlyAt: card.only_at ?? null,
|
|
143
175
|
bonus: bonusStatus(catalog, wallet, held, today),
|
|
144
176
|
currency: valuation.currency,
|
|
177
|
+
currencyName: valuation.currency === 'cash'
|
|
178
|
+
? 'cash back'
|
|
179
|
+
: currencyLabel(catalog, valuation.currency),
|
|
145
180
|
centsPerUnit: valuation.centsPerUnit,
|
|
146
181
|
lastVerified: card.last_verified,
|
|
147
182
|
verifiedBy: card.verified_by,
|
|
@@ -152,7 +187,10 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
152
187
|
? {
|
|
153
188
|
period: held.chosen.period,
|
|
154
189
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
155
|
-
|
|
190
|
+
// Picks that carry over have no end date.
|
|
191
|
+
until: card.choices.every((c) => c.period === 'until_changed')
|
|
192
|
+
? null
|
|
193
|
+
: periodEnd(held.chosen.period),
|
|
156
194
|
picks: held.chosen.picks,
|
|
157
195
|
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
158
196
|
choiceName(choice),
|
|
@@ -160,11 +198,19 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
160
198
|
])),
|
|
161
199
|
}
|
|
162
200
|
: null,
|
|
163
|
-
rates: headlineRates(catalog, card, valuation.currency, today),
|
|
201
|
+
rates: headlineRates(catalog, wallet, card, held, valuation.currency, today),
|
|
164
202
|
activated: held.activated,
|
|
165
203
|
toActivate: toActivate(catalog, card, held, today),
|
|
166
204
|
anniversary: held.anniversary ?? null,
|
|
167
205
|
});
|
|
168
206
|
}
|
|
169
|
-
|
|
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 };
|
|
170
216
|
}
|
package/dist/format/card-text.js
CHANGED
|
@@ -13,31 +13,12 @@ const ISSUERS = {
|
|
|
13
13
|
usbank: 'U.S. Bank',
|
|
14
14
|
'wells-fargo': 'Wells Fargo',
|
|
15
15
|
};
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
'bofa-rewards-member': 'BofA Rewards Member',
|
|
20
|
-
'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
|
|
21
|
-
'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
|
|
22
|
-
'bofa-rewards-premier': 'BofA Rewards Premier',
|
|
23
|
-
};
|
|
24
|
-
export const PICK_PERIODS = {
|
|
25
|
-
month: 'each month',
|
|
26
|
-
quarter: 'each quarter',
|
|
27
|
-
calendar_year: 'each year',
|
|
28
|
-
until_changed: 'until you change it',
|
|
29
|
-
};
|
|
30
|
-
export const CAP_PERIODS = {
|
|
31
|
-
month: 'a month',
|
|
32
|
-
quarter: 'a quarter',
|
|
33
|
-
calendar_year: 'a year',
|
|
34
|
-
cardmember_year: 'a card year',
|
|
35
|
-
};
|
|
36
|
-
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
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";
|
|
37
19
|
// Whole dollars, for fees and caps.
|
|
38
|
-
export const usd =
|
|
20
|
+
export const usd = wholeDollars;
|
|
39
21
|
export const issuerName = (id) => ISSUERS[id] ?? id;
|
|
40
|
-
export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
|
|
41
22
|
export function cardText(catalog) {
|
|
42
23
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
43
24
|
function shortName(id) {
|
|
@@ -50,15 +31,17 @@ export function cardText(catalog) {
|
|
|
50
31
|
}
|
|
51
32
|
function feeLabel(id) {
|
|
52
33
|
const card = cards.get(id);
|
|
34
|
+
const m = card?.requires_membership;
|
|
35
|
+
const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
|
|
53
36
|
const fee = card?.annual_fee;
|
|
54
37
|
if (fee === undefined)
|
|
55
38
|
return 'Annual fee not on file';
|
|
56
39
|
if (fee === 0)
|
|
57
|
-
return
|
|
40
|
+
return `No annual fee${needs}`;
|
|
58
41
|
const first = card?.first_year_fee;
|
|
59
42
|
if (first !== undefined && first !== fee)
|
|
60
|
-
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
|
|
61
|
-
return `${usd(fee)} a year`;
|
|
43
|
+
return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)${needs}`;
|
|
44
|
+
return `${usd(fee)} a year${needs}`;
|
|
62
45
|
}
|
|
63
46
|
// `until` is the last day a time-limited rate applies, already formatted.
|
|
64
47
|
function requirementLines(cardId, r, until = null) {
|
|
@@ -68,12 +51,19 @@ export function cardText(catalog) {
|
|
|
68
51
|
if (r.activation !== null)
|
|
69
52
|
lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
|
|
70
53
|
if (r.membership !== null)
|
|
71
|
-
lines.push(`With ${membershipName(r.membership)}`);
|
|
54
|
+
lines.push(`With ${membershipName(catalog, r.membership)}`);
|
|
72
55
|
if (until !== null)
|
|
73
56
|
lines.push(`This rate runs through ${until}`);
|
|
74
57
|
if (r.cap)
|
|
75
|
-
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)}`);
|
|
76
59
|
return lines;
|
|
77
60
|
}
|
|
78
|
-
|
|
61
|
+
const memberName = (id) => membershipName(catalog, id);
|
|
62
|
+
return {
|
|
63
|
+
cards,
|
|
64
|
+
shortName,
|
|
65
|
+
feeLabel,
|
|
66
|
+
requirementLines,
|
|
67
|
+
membershipName: memberName,
|
|
68
|
+
};
|
|
79
69
|
}
|
package/dist/format/cards.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
visa: 'Visa',
|
|
3
|
-
mastercard: 'Mastercard',
|
|
4
|
-
amex: 'Amex',
|
|
5
|
-
discover: 'Discover',
|
|
6
|
-
store: 'store card',
|
|
7
|
-
};
|
|
1
|
+
import { money, NETWORK_NAMES } from "../engine/text.js";
|
|
8
2
|
export function formatCardInfo(card) {
|
|
9
3
|
const fee = card.annualFee === null
|
|
10
4
|
? ''
|
|
11
5
|
: card.annualFee === 0
|
|
12
6
|
? ', no annual fee'
|
|
13
|
-
: `,
|
|
7
|
+
: `, ${money(card.annualFee)} a year`;
|
|
14
8
|
const needs = [
|
|
15
9
|
...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
|
|
10
|
+
...(card.requiresMembership
|
|
11
|
+
? [
|
|
12
|
+
`needs ${card.requiresMembership.name} (${money(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
|
|
13
|
+
]
|
|
14
|
+
: []),
|
|
16
15
|
...(card.memberships.length > 0
|
|
17
16
|
? [`rates that need a membership: ${card.memberships.join(', ')}`]
|
|
18
17
|
: []),
|
|
@@ -23,7 +22,7 @@ export function formatCardInfo(card) {
|
|
|
23
22
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
23
|
];
|
|
25
24
|
return [
|
|
26
|
-
`${card.id}: ${card.name} (${
|
|
25
|
+
`${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
|
|
27
26
|
...needs.map((n) => ` ${n}`),
|
|
28
27
|
].join('\n');
|
|
29
28
|
}
|
package/dist/format/explore.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { membershipName, membershipNote, } from "../engine/memberships.js";
|
|
2
|
+
import { choiceName, feeText, money, pct, wholeDollars, words, } from "../engine/text.js";
|
|
3
|
+
const membershipFees = (memberships) => memberships
|
|
4
|
+
.map((m) => ` + ${wholeDollars(m.annualFee)} for ${m.name}`)
|
|
5
|
+
.join('');
|
|
3
6
|
const TOP_BEST = 10;
|
|
4
|
-
export function formatExploration(r, all) {
|
|
7
|
+
export function formatExploration(r, catalog, all) {
|
|
5
8
|
const m = r.merchant;
|
|
6
9
|
const title = m.source === 'category'
|
|
7
10
|
? m.resolvedName.toLowerCase()
|
|
@@ -20,7 +23,7 @@ export function formatExploration(r, all) {
|
|
|
20
23
|
lines.push(` ${c}`);
|
|
21
24
|
const upgrade = e.withMembership[0];
|
|
22
25
|
if (upgrade)
|
|
23
|
-
lines.push(` With ${upgrade.membership}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
26
|
+
lines.push(` With ${membershipName(catalog, upgrade.membership)}: ${pct(upgrade.effectiveReturnPct)}.`);
|
|
24
27
|
for (const w of e.warnings)
|
|
25
28
|
lines.push(` ! ${w}`);
|
|
26
29
|
});
|
|
@@ -32,8 +35,8 @@ export function formatExploration(r, all) {
|
|
|
32
35
|
return `${lines.join('\n')}\n`;
|
|
33
36
|
}
|
|
34
37
|
function picksText(picks) {
|
|
35
|
-
const parts = Object.entries(picks).map(([choice, options]) => `${choice}
|
|
36
|
-
return parts.length === 0 ? '' : ` (
|
|
38
|
+
const parts = Object.entries(picks).map(([choice, options]) => `${options.map(words).join(' and ')} as its ${choiceName(choice)} ${options.length === 1 ? 'pick' : 'picks'}`);
|
|
39
|
+
return parts.length === 0 ? '' : ` (with ${parts.join('; ')})`;
|
|
37
40
|
}
|
|
38
41
|
function cardNamer(catalog) {
|
|
39
42
|
return (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
@@ -56,7 +59,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
56
59
|
return 'No cards match.\n';
|
|
57
60
|
const top = all ? 20 : TOP_SIMULATED;
|
|
58
61
|
const lines = [
|
|
59
|
-
`A year of this spending: ${
|
|
62
|
+
`A year of this spending: ${wholeDollars(first.spend)}.`,
|
|
60
63
|
'',
|
|
61
64
|
'Best single cards (rewards minus annual fee, per year):',
|
|
62
65
|
];
|
|
@@ -64,13 +67,13 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
64
67
|
const card = s.cards[0];
|
|
65
68
|
if (!card)
|
|
66
69
|
return;
|
|
67
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${
|
|
70
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(card.cardId)} ${wholeDollars(s.net)} (${wholeDollars(s.rewards)} rewards, ${wholeDollars(s.annualFees)} fee${membershipFees(s.memberships)})${picksText(card.picks)}`);
|
|
68
71
|
});
|
|
69
72
|
const pairs = r.pairs.slice(0, top);
|
|
70
73
|
if (pairs.length > 0) {
|
|
71
74
|
lines.push('', 'Best two-card pairs:');
|
|
72
75
|
pairs.forEach((s, i) => {
|
|
73
|
-
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${
|
|
76
|
+
lines.push(`${String(i + 1).padStart(2)}. ${s.cards.map((c) => `${name(c.cardId)}${picksText(c.picks)}`).join(' + ')} ${wholeDollars(s.net)}`);
|
|
74
77
|
if (s.alternatives.length > 0)
|
|
75
78
|
lines.push(` Same result with ${s.alternatives.map(name).join(' or ')} as the second card.`);
|
|
76
79
|
for (const split of splitText(s, name))
|
|
@@ -92,7 +95,7 @@ export function formatCatalogSimulation(r, catalog, all) {
|
|
|
92
95
|
export function formatAdditions(r, catalog, all) {
|
|
93
96
|
const name = cardNamer(catalog);
|
|
94
97
|
const lines = [
|
|
95
|
-
`Your wallet earns about ${
|
|
98
|
+
`Your wallet earns about ${wholeDollars(r.current.rewards)} a year on this spending${r.current.annualFees > 0 ? `, with ${wholeDollars(r.current.annualFees)} in annual fees: ${wholeDollars(r.current.net)} after fees` : ''}.`,
|
|
96
99
|
];
|
|
97
100
|
for (const split of splitText(r.current, name))
|
|
98
101
|
lines.push(` ${split}`);
|
|
@@ -101,14 +104,19 @@ export function formatAdditions(r, catalog, all) {
|
|
|
101
104
|
lines.push('', 'No card would add more than its annual fee costs.');
|
|
102
105
|
}
|
|
103
106
|
else {
|
|
104
|
-
lines.push('', 'Cards that would add the most (after their annual fee):');
|
|
107
|
+
lines.push('', 'Cards that would add the most (after their annual fee and any membership they need):');
|
|
105
108
|
gains.slice(0, all ? 20 : TOP_SIMULATED).forEach((a, i) => {
|
|
106
|
-
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${
|
|
109
|
+
lines.push(`${String(i + 1).padStart(2)}. ${name(a.cardId)} +${wholeDollars(a.gain)} a year (+${wholeDollars(a.extraRewards)} rewards, ${wholeDollars(a.annualFee)} fee${membershipFees(a.simulation.memberships.filter((m) => m.cardIds.includes(a.cardId)))})${picksText(a.picks)}`);
|
|
107
110
|
});
|
|
108
111
|
}
|
|
109
|
-
const notes = new Set(
|
|
112
|
+
const notes = new Set([
|
|
113
|
+
...r.current.notes,
|
|
114
|
+
...gains
|
|
115
|
+
.slice(0, all ? 20 : TOP_SIMULATED)
|
|
116
|
+
.flatMap((a) => a.simulation.memberships.map((m) => membershipNote(catalog, m))),
|
|
117
|
+
]);
|
|
110
118
|
if (notes.size > 0)
|
|
111
119
|
lines.push('', ...notes);
|
|
112
|
-
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh.');
|
|
120
|
+
lines.push('', 'Assumes your picks carry over, rotating categories get activated, and caps start fresh. Annual fees are counted in full: statement credits and perks that offset them are left out. Each kind of spending lists the card that earns the most on it over the whole year, so a one-quarter bonus may not show.');
|
|
113
121
|
return `${lines.join('\n')}\n`;
|
|
114
122
|
}
|
package/dist/format/offers.js
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { offerTerms } from "../engine/offers.js";
|
|
2
|
+
import { feeText, money, shortDate, wholeDollars } from "../engine/text.js";
|
|
2
3
|
const TOP = 10;
|
|
3
|
-
function reward(o, currencyName) {
|
|
4
|
-
const amount = o.dollars !== null
|
|
5
|
-
? money(o.dollars)
|
|
6
|
-
: `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
7
|
-
return o.varies ? `Up to ${amount}` : amount;
|
|
8
|
-
}
|
|
9
|
-
const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
-
month: 'short',
|
|
11
|
-
day: 'numeric',
|
|
12
|
-
year: 'numeric',
|
|
13
|
-
timeZone: 'UTC',
|
|
14
|
-
});
|
|
15
4
|
export function formatOffers(c, currencyName, all) {
|
|
16
5
|
if (c.offers.length === 0) {
|
|
17
6
|
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
@@ -21,17 +10,19 @@ export function formatOffers(c, currencyName, all) {
|
|
|
21
10
|
const lines = [];
|
|
22
11
|
shown.forEach((o, i) => {
|
|
23
12
|
const weeks = (o.months * 52) / 12;
|
|
24
|
-
const pace = o.spend > 0 ? ` (about ${
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
|
|
13
|
+
const pace = o.spend > 0 ? ` (about ${wholeDollars(o.spend / weeks)} a week)` : '';
|
|
14
|
+
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${wholeDollars(o.firstYearValue)} the first year`, ` ${offerTerms(o, currencyName(o.currency))}${pace}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.membershipFee > 0 ? ` Needs a ${money(o.membershipFee)} membership, counted in the first year.` : ''}${o.held ? ' In your wallet.' : ''}`);
|
|
15
|
+
lines.push(` ${o.validTo ? `Offer ends ${shortDate(o.validTo)}. ` : ''}Public offer as of ${shortDate(o.lastVerified)}; yours may differ.`);
|
|
16
|
+
if (o.ineligible)
|
|
17
|
+
lines.push(` ${o.ineligible}`);
|
|
30
18
|
if (o.note)
|
|
31
19
|
lines.push(` ${o.note}`);
|
|
32
20
|
});
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
const ruledOut = c.offers.filter((o) => o.ineligible !== null && !shown.includes(o));
|
|
22
|
+
if (ruledOut.length > 0)
|
|
23
|
+
lines.push(` ${String(ruledOut.length)} more left out as likely not eligible with a card you hold: ${ruledOut.map((o) => o.cardName).join(', ')}.`);
|
|
24
|
+
if (c.offers.length > shown.length + ruledOut.length)
|
|
25
|
+
lines.push(` + ${String(c.offers.length - shown.length - ruledOut.length)} more (--all lists them)`);
|
|
35
26
|
if (c.withoutOffer.length > 0)
|
|
36
27
|
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
37
28
|
lines.push('', ...c.notes);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { money, pct } from "../engine/text.js";
|
|
1
|
+
import { dollarsAndCents, money, pct } from "../engine/text.js";
|
|
2
|
+
import { noCardText } from "../engine/recommend.js";
|
|
2
3
|
export const plain = (_format, text) => text;
|
|
3
4
|
const TOP = 3;
|
|
4
|
-
const cents = (n) => `$${n.toFixed(2)}`;
|
|
5
5
|
const label = (c) => c.checkoutLabel ?? c.cardName;
|
|
6
6
|
function header(rec, purchase) {
|
|
7
7
|
const m = rec.merchant;
|
|
@@ -24,13 +24,15 @@ function header(rec, purchase) {
|
|
|
24
24
|
parts.push(m.channel === 'online' ? 'online' : 'in-store');
|
|
25
25
|
return parts.join(' · ');
|
|
26
26
|
}
|
|
27
|
-
function cardLines(cards, style) {
|
|
27
|
+
function cardLines(cards, style, bonusPick) {
|
|
28
|
+
const rankWidth = Math.max(...cards.map((c) => c.rank));
|
|
28
29
|
const nameWidth = Math.max(...cards.map((c) => label(c).length));
|
|
29
30
|
const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
|
|
30
31
|
const lines = [];
|
|
31
|
-
cards.forEach((c
|
|
32
|
-
const earned = c.earned === null ? '' : ` ${
|
|
33
|
-
|
|
32
|
+
cards.forEach((c) => {
|
|
33
|
+
const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
|
|
34
|
+
const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
|
|
35
|
+
lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
|
|
34
36
|
for (const r of c.reasoning)
|
|
35
37
|
lines.push(style('dim', ` ${r}`));
|
|
36
38
|
for (const w of c.warnings)
|
|
@@ -48,9 +50,9 @@ function excludedLines(excluded) {
|
|
|
48
50
|
export function formatRecommendation(rec, purchase, { all = false, style = plain, notices = [], } = {}) {
|
|
49
51
|
const lines = [header(rec, purchase), ''];
|
|
50
52
|
const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId) ?? rec.ranked[0];
|
|
51
|
-
const noCard =
|
|
53
|
+
const noCard = noCardText(rec.merchant.resolvedName);
|
|
52
54
|
if (rec.decision && top) {
|
|
53
|
-
const earned = top.earned === null ? '' : `, about ${
|
|
55
|
+
const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
|
|
54
56
|
const confidence = rec.decision.confidence;
|
|
55
57
|
const why = rec.bonus ? ' for its welcome bonus' : '';
|
|
56
58
|
lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${confidence[0]?.toUpperCase() ?? ''}${confidence.slice(1)} confidence.`);
|
|
@@ -60,9 +62,13 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
|
|
|
60
62
|
else {
|
|
61
63
|
lines.push(style('bold', noCard));
|
|
62
64
|
}
|
|
63
|
-
const
|
|
65
|
+
const bonusPick = rec.bonus?.cardId ?? null;
|
|
66
|
+
const ranked = rec.ranked.map((c, i) => ({ ...c, rank: i + 1 }));
|
|
67
|
+
const shown = all
|
|
68
|
+
? ranked
|
|
69
|
+
: ranked.filter((c, i) => i < TOP || c.cardId === bonusPick);
|
|
64
70
|
if (shown.length > 0)
|
|
65
|
-
lines.push('', ...cardLines(shown, style));
|
|
71
|
+
lines.push('', ...cardLines(shown, style, bonusPick));
|
|
66
72
|
const hidden = rec.ranked.length - shown.length;
|
|
67
73
|
if (hidden > 0) {
|
|
68
74
|
lines.push(style('dim', ` + ${String(hidden)} more ${hidden === 1 ? 'card' : 'cards'} (--all lists them)`));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { parse,
|
|
1
|
+
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
3
|
+
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
5
5
|
// website, the remote server and the command line. Node-free.
|
|
6
6
|
function compact(wallet) {
|
|
7
7
|
return {
|
|
8
|
+
version: wallet.version ?? WALLET_VERSION,
|
|
8
9
|
...(Object.keys(wallet.valuations).length > 0
|
|
9
10
|
? { valuations: wallet.valuations }
|
|
10
11
|
: {}),
|
|
@@ -26,8 +27,22 @@ function compact(wallet) {
|
|
|
26
27
|
})),
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
|
|
31
|
+
// date, not a string.
|
|
32
|
+
function quotedYaml(value) {
|
|
33
|
+
const doc = new Document(value);
|
|
34
|
+
visit(doc, {
|
|
35
|
+
Scalar(key, node) {
|
|
36
|
+
if (key === 'value' &&
|
|
37
|
+
typeof node.value === 'string' &&
|
|
38
|
+
/^(\d{4}(-|$)|\d{2}-\d{2}$)/.test(node.value))
|
|
39
|
+
node.type = 'QUOTE_DOUBLE';
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
return doc.toString();
|
|
43
|
+
}
|
|
29
44
|
export function walletYaml(wallet) {
|
|
30
|
-
return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${
|
|
45
|
+
return `# Your earnrate wallet: the cards you hold, by id. Never card numbers.\n# For the command line, save it as ~/.earnrate/wallet.yaml\n\n${quotedYaml(compact(wallet))}`;
|
|
31
46
|
}
|
|
32
47
|
export function parseWalletYaml(text, catalog) {
|
|
33
48
|
let raw;
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,24 +1,11 @@
|
|
|
1
|
-
import { money } from "../engine/text.js";
|
|
2
|
-
const NETWORKS = {
|
|
3
|
-
visa: 'Visa',
|
|
4
|
-
mastercard: 'Mastercard',
|
|
5
|
-
amex: 'American Express',
|
|
6
|
-
discover: 'Discover',
|
|
7
|
-
store: 'store card',
|
|
8
|
-
};
|
|
9
|
-
const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
-
month: 'short',
|
|
11
|
-
day: 'numeric',
|
|
12
|
-
year: 'numeric',
|
|
13
|
-
timeZone: 'UTC',
|
|
14
|
-
});
|
|
1
|
+
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
15
2
|
export function formatWalletSummary(summary) {
|
|
16
3
|
const cards = summary.cards.map((c) => {
|
|
17
4
|
const label = c.checkoutLabel
|
|
18
5
|
? `, shown at checkout as "${c.checkoutLabel}"`
|
|
19
6
|
: '';
|
|
20
7
|
const lines = [
|
|
21
|
-
`- ${c.cardName} (${c.cardId})${label}: ${
|
|
8
|
+
`- ${c.cardName} (${c.cardId})${label}: ${NETWORK_NAMES[c.network] ?? c.network}, ${c.currencyName} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
|
|
22
9
|
` Earns: ${c.rates.join('; ')}.`,
|
|
23
10
|
];
|
|
24
11
|
if (c.onlyAt)
|
|
@@ -27,10 +14,14 @@ export function formatWalletSummary(summary) {
|
|
|
27
14
|
const picks = Object.entries(c.chosen.pickNames)
|
|
28
15
|
.map(([choice, names]) => `${choice}: ${names.join(', ')}`)
|
|
29
16
|
.join('; ');
|
|
30
|
-
const until = c.chosen.until
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
17
|
+
const until = c.chosen.until
|
|
18
|
+
? `, through ${shortDate(c.chosen.until)}`
|
|
19
|
+
: '';
|
|
20
|
+
lines.push(!c.chosen.current
|
|
21
|
+
? ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`
|
|
22
|
+
: c.chosen.until === null
|
|
23
|
+
? ` Picks: ${picks}. They stay until the user changes them.`
|
|
24
|
+
: ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`);
|
|
34
25
|
}
|
|
35
26
|
if (c.toActivate.length > 0)
|
|
36
27
|
lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
|
|
@@ -39,6 +30,8 @@ export function formatWalletSummary(summary) {
|
|
|
39
30
|
for (const cap of c.caps)
|
|
40
31
|
if (cap.remaining !== null)
|
|
41
32
|
lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
|
|
33
|
+
if (c.anniversary !== null)
|
|
34
|
+
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
42
35
|
const b = c.bonus;
|
|
43
36
|
if (b?.state === 'open' && b.spend !== null)
|
|
44
37
|
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
|
@@ -49,7 +42,10 @@ export function formatWalletSummary(summary) {
|
|
|
49
42
|
const memberships = summary.memberships.length > 0
|
|
50
43
|
? `Memberships: ${summary.memberships.join(', ')}.`
|
|
51
44
|
: 'Memberships: none saved.';
|
|
52
|
-
|
|
45
|
+
const values = summary.pointValues.length > 0
|
|
46
|
+
? `Point values in the wallet: ${summary.pointValues.map((v) => `${v.name} at ${String(v.centsPerUnit)}¢${v.redemption === null ? '' : ` (redeemed ${v.redemption})`}`).join(', ')}. Others use earnrate's defaults.`
|
|
47
|
+
: "Point values: earnrate's defaults.";
|
|
48
|
+
return [...cards, memberships, values].join('\n');
|
|
53
49
|
}
|
|
54
50
|
export function formatBooking(amount, cardId, cap, bonus) {
|
|
55
51
|
const messages = [];
|