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
package/dist/data/integrity.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WALLET_VERSION } from "../schema/wallet.js";
|
|
1
2
|
function duplicates(ids) {
|
|
2
3
|
const seen = new Set();
|
|
3
4
|
const dupes = new Set();
|
|
@@ -56,6 +57,22 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
56
57
|
if (!currencies.has(card.currency)) {
|
|
57
58
|
problems.push(`${where}: unknown currency '${card.currency}'`);
|
|
58
59
|
}
|
|
60
|
+
for (const id of card.welcome_offer?.not_with ?? [])
|
|
61
|
+
if (!cardIds.has(id))
|
|
62
|
+
problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
|
|
63
|
+
const memberships = new Set(catalog.memberships.map((m) => m.id));
|
|
64
|
+
for (const id of [
|
|
65
|
+
...(card.requires_membership === undefined
|
|
66
|
+
? []
|
|
67
|
+
: [card.requires_membership]),
|
|
68
|
+
...card.rules.flatMap((r) => r.requires?.membership ?? []),
|
|
69
|
+
])
|
|
70
|
+
if (!memberships.has(id))
|
|
71
|
+
problems.push(`${where}: unknown membership '${id}' (data/memberships.yaml)`);
|
|
72
|
+
if (card.requires_membership !== undefined &&
|
|
73
|
+
catalog.memberships.find((m) => m.id === card.requires_membership)
|
|
74
|
+
?.annual_fee === undefined)
|
|
75
|
+
problems.push(`${where}: requires_membership '${card.requires_membership}' has no annual_fee`);
|
|
59
76
|
if (card.pools_into) {
|
|
60
77
|
if (!currencies.has(card.pools_into.currency)) {
|
|
61
78
|
problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
|
|
@@ -79,6 +96,8 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
79
96
|
...(card.annual_fee !== undefined ? ['annual_fee'] : []),
|
|
80
97
|
...(card.first_year_fee !== undefined ? ['first_year_fee'] : []),
|
|
81
98
|
...(card.welcome_offer ? ['welcome_offer'] : []),
|
|
99
|
+
...(card.welcome_offer?.not_with ? ['not_with'] : []),
|
|
100
|
+
...(card.requires_membership ? ['requires_membership'] : []),
|
|
82
101
|
...card.rules.map((r) => r.id),
|
|
83
102
|
]);
|
|
84
103
|
const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
|
|
@@ -159,6 +178,7 @@ export function catalogProblems(catalog) {
|
|
|
159
178
|
['category', catalog.categories.map((c) => c.id)],
|
|
160
179
|
['currency', catalog.currencies.map((c) => c.id)],
|
|
161
180
|
['card', catalog.cards.map((c) => c.id)],
|
|
181
|
+
['membership', catalog.memberships.map((m) => m.id)],
|
|
162
182
|
]) {
|
|
163
183
|
for (const id of duplicates(ids))
|
|
164
184
|
problems.push(`duplicate ${kind} id '${id}'`);
|
|
@@ -179,6 +199,10 @@ export function catalogProblems(catalog) {
|
|
|
179
199
|
problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
|
|
180
200
|
}
|
|
181
201
|
}
|
|
202
|
+
for (const m of catalog.memberships)
|
|
203
|
+
for (const id of m.members_shop_at)
|
|
204
|
+
if (!merchantIds.has(id))
|
|
205
|
+
problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
|
|
182
206
|
for (const card of catalog.cards) {
|
|
183
207
|
problems.push(...cardProblems(card, catalog, merchantIds));
|
|
184
208
|
}
|
|
@@ -188,6 +212,8 @@ export function walletProblems(wallet, catalog) {
|
|
|
188
212
|
const problems = [];
|
|
189
213
|
const cards = new Map(catalog.cards.map((c) => [c.id, c]));
|
|
190
214
|
const currencies = new Map(catalog.currencies.map((c) => [c.id, c]));
|
|
215
|
+
if ((wallet.version ?? 1) > WALLET_VERSION)
|
|
216
|
+
problems.push(`wallet: saved by a newer earnrate (format ${String(wallet.version)}); update earnrate to read it`);
|
|
191
217
|
for (const id of duplicates(wallet.cards.map((c) => c.id))) {
|
|
192
218
|
problems.push(`wallet: card '${id}' is listed twice`);
|
|
193
219
|
}
|
package/dist/data/load.js
CHANGED
|
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
|
|
|
8
8
|
import { CardSchema } from "../schema/card.js";
|
|
9
9
|
import { CategoriesFileSchema } from "../schema/category.js";
|
|
10
10
|
import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
11
|
+
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
11
12
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
12
13
|
import { WalletSchema } from "../schema/wallet.js";
|
|
13
14
|
export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
|
|
@@ -30,14 +31,21 @@ async function loadCards(dir) {
|
|
|
30
31
|
}));
|
|
31
32
|
}
|
|
32
33
|
export async function loadCatalog(dir = DATA_DIR) {
|
|
33
|
-
const [categories, currencies, cards, seed, overrides] = await Promise.all([
|
|
34
|
+
const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
|
|
34
35
|
readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
|
|
35
36
|
readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
|
|
37
|
+
readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
|
|
36
38
|
loadCards(path.join(dir, 'cards')),
|
|
37
39
|
readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
|
|
38
40
|
readYaml(path.join(dir, 'merchants', 'overrides.yaml'), MerchantsFileSchema),
|
|
39
41
|
]);
|
|
40
|
-
return {
|
|
42
|
+
return {
|
|
43
|
+
categories,
|
|
44
|
+
currencies,
|
|
45
|
+
memberships,
|
|
46
|
+
cards,
|
|
47
|
+
merchants: { seed, overrides },
|
|
48
|
+
};
|
|
41
49
|
}
|
|
42
50
|
// MCP clients pass config values through without a shell, so "~" or
|
|
43
51
|
// "${HOME}" can arrive as literal text; expand them here.
|
package/dist/data/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
const failed = (error) => ({ ok: false, error });
|
|
5
5
|
// A wallet stored as JSON text. Writes are serialized within this instance;
|
|
@@ -60,6 +60,9 @@ export class JsonWalletStore {
|
|
|
60
60
|
setMemberships(memberships) {
|
|
61
61
|
return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
|
|
62
62
|
}
|
|
63
|
+
setValuations(changes) {
|
|
64
|
+
return this.change(this.needsWallet((w) => withValuations(this.catalog, w, changes)));
|
|
65
|
+
}
|
|
63
66
|
replace(wallet) {
|
|
64
67
|
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
65
68
|
}
|
|
@@ -70,6 +73,10 @@ export class JsonWalletStore {
|
|
|
70
73
|
disconnect() {
|
|
71
74
|
return Promise.resolve();
|
|
72
75
|
}
|
|
76
|
+
// The backend doesn't record when it began.
|
|
77
|
+
started() {
|
|
78
|
+
return Promise.resolve(null);
|
|
79
|
+
}
|
|
73
80
|
record(purchase, merchant) {
|
|
74
81
|
return this.serialized(async () => {
|
|
75
82
|
const wallet = await this.load();
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import { isMap, isSeq, parseDocument, stringify } from 'yaml';
|
|
5
5
|
import { applyBonusBooking, applyBooking, } from "../engine/record.js";
|
|
6
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
6
|
+
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
7
7
|
const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
|
|
8
8
|
# Format: https://github.com/cwasley/earnrate#your-wallet
|
|
9
9
|
|
|
@@ -57,6 +57,12 @@ function fieldLines(field, f, indent) {
|
|
|
57
57
|
`${indent} spent: ${String(b.spent)}\n`,
|
|
58
58
|
b.spend === undefined ? '' : `${indent} spend: ${String(b.spend)}\n`,
|
|
59
59
|
b.by === undefined ? '' : `${indent} by: ${scalar(b.by)}\n`,
|
|
60
|
+
b.points === undefined
|
|
61
|
+
? ''
|
|
62
|
+
: `${indent} points: ${String(b.points)}\n`,
|
|
63
|
+
b.dollars === undefined
|
|
64
|
+
? ''
|
|
65
|
+
: `${indent} dollars: ${String(b.dollars)}\n`,
|
|
60
66
|
].join('');
|
|
61
67
|
}
|
|
62
68
|
case 'chosen': {
|
|
@@ -100,7 +106,7 @@ const column = (text, at) => at - lineStart(text, at);
|
|
|
100
106
|
// Appends a card after the last one, matching the list's indentation.
|
|
101
107
|
export function addCardText(text, entry) {
|
|
102
108
|
if (text === null || text.trim() === '') {
|
|
103
|
-
return `${NEW_WALLET}
|
|
109
|
+
return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
|
|
104
110
|
}
|
|
105
111
|
const { cards } = cardsSeq(text);
|
|
106
112
|
const first = cards.items[0];
|
|
@@ -164,6 +170,81 @@ export function setMembershipsText(text, memberships) {
|
|
|
164
170
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
165
171
|
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
166
172
|
}
|
|
173
|
+
// Point values, one currency at a time so the rest of the file, comments
|
|
174
|
+
// included, stays as it was: a value is replaced in place, a new one goes
|
|
175
|
+
// after the last, a removed one loses its line. An inline map is rewritten.
|
|
176
|
+
export function setValuationsText(text, changes) {
|
|
177
|
+
let out = text;
|
|
178
|
+
for (const [id, cents] of Object.entries(changes))
|
|
179
|
+
out = setValuationText(out, id, cents);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
// The start of the line at `at`, moved up past the comments right above it,
|
|
183
|
+
// so a comment stays with the line it describes.
|
|
184
|
+
function aboveComments(text, at) {
|
|
185
|
+
let start = lineStart(text, at);
|
|
186
|
+
while (start > 0) {
|
|
187
|
+
const previous = lineStart(text, start - 1);
|
|
188
|
+
if (!text.slice(previous, start).trimStart().startsWith('#'))
|
|
189
|
+
break;
|
|
190
|
+
start = previous;
|
|
191
|
+
}
|
|
192
|
+
return start;
|
|
193
|
+
}
|
|
194
|
+
function setValuationText(text, id, cents) {
|
|
195
|
+
const { doc } = cardsSeq(text);
|
|
196
|
+
const root = doc.contents;
|
|
197
|
+
if (!isMap(root))
|
|
198
|
+
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
199
|
+
const pair = root.items.find((p) => keyOf(p) === 'valuations');
|
|
200
|
+
const map = pair?.value;
|
|
201
|
+
const line = cents === null ? '' : `${id}: ${String(cents)}`;
|
|
202
|
+
if (!pair || map === null || (isMap(map) && map.items.length === 0)) {
|
|
203
|
+
if (cents === null)
|
|
204
|
+
return text;
|
|
205
|
+
const block = `valuations:\n ${line}\n`;
|
|
206
|
+
if (pair) {
|
|
207
|
+
const start = range(pair.key)[0];
|
|
208
|
+
const end = afterLine(text, range(map ?? pair.key)[1] - 1);
|
|
209
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
210
|
+
}
|
|
211
|
+
const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
|
|
212
|
+
return insertLines(text, aboveComments(text, range(before?.key)[0]), block);
|
|
213
|
+
}
|
|
214
|
+
if (!isMap(map))
|
|
215
|
+
throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
|
|
216
|
+
if (map.flow) {
|
|
217
|
+
const values = Object.entries(map.toJSON()).filter(([k]) => k !== id);
|
|
218
|
+
if (cents !== null)
|
|
219
|
+
values.push([id, cents]);
|
|
220
|
+
const entries = values.map(([k, v]) => `${k}: ${stringify(v, { flow: true }).trimEnd()}`);
|
|
221
|
+
const start = range(pair.key)[0];
|
|
222
|
+
const end = afterLine(text, range(map)[1] - 1);
|
|
223
|
+
return (text.slice(0, start) +
|
|
224
|
+
(entries.length === 0 ? '' : `valuations: { ${entries.join(', ')} }\n`) +
|
|
225
|
+
text.slice(end));
|
|
226
|
+
}
|
|
227
|
+
const own = map.items.find((p) => keyOf(p) === id);
|
|
228
|
+
if (own) {
|
|
229
|
+
if (cents === null) {
|
|
230
|
+
if (map.items.length === 1) {
|
|
231
|
+
const start = lineStart(text, range(pair.key)[0]);
|
|
232
|
+
return (text.slice(0, start) + text.slice(afterLine(text, range(map)[1] - 1)));
|
|
233
|
+
}
|
|
234
|
+
const start = lineStart(text, range(own.key)[0]);
|
|
235
|
+
return (text.slice(0, start) +
|
|
236
|
+
text.slice(afterLine(text, range(own.value)[1] - 1)));
|
|
237
|
+
}
|
|
238
|
+
const [start, end] = range(own.value);
|
|
239
|
+
return text.slice(0, start) + String(cents) + text.slice(end);
|
|
240
|
+
}
|
|
241
|
+
if (cents === null)
|
|
242
|
+
return text;
|
|
243
|
+
const first = map.items[0];
|
|
244
|
+
const last = map.items.at(-1);
|
|
245
|
+
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
246
|
+
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
247
|
+
}
|
|
167
248
|
function flowUsage(booking) {
|
|
168
249
|
const period = /^\d+$/.test(booking.period)
|
|
169
250
|
? `"${booking.period}"`
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
2
|
import { categoryResolution, exploreCards, } from "./explore.js";
|
|
3
|
+
import { bonusStatus } from "./bonus.js";
|
|
4
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
3
5
|
import { periodKey } from "./periods.js";
|
|
4
6
|
import { recommend } from "./recommend.js";
|
|
5
7
|
import { setupNotes } from "./setup.js";
|
|
6
|
-
|
|
8
|
+
import { feeText, listed, money, shortDate } from "./text.js";
|
|
9
|
+
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
10
|
+
// quick picks.
|
|
11
|
+
export const EVERYDAY_PURCHASES = [
|
|
7
12
|
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
8
13
|
{ label: 'Dining', category: 'dining' },
|
|
9
14
|
{ label: 'Gas', category: 'gas' },
|
|
@@ -16,7 +21,8 @@ const CHEAT_SHEET_ITEMS = [
|
|
|
16
21
|
];
|
|
17
22
|
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
18
23
|
// with caps, picks and activations as the wallet has them.
|
|
19
|
-
export function cheatSheet(catalog, wallet, date, items =
|
|
24
|
+
export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
25
|
+
const ranked = [];
|
|
20
26
|
const rows = items.map((item) => {
|
|
21
27
|
const merchant = 'merchant' in item
|
|
22
28
|
? resolveMerchantById(catalog, item.merchant, null)
|
|
@@ -30,7 +36,10 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
30
36
|
date,
|
|
31
37
|
country: 'US',
|
|
32
38
|
}, merchant);
|
|
33
|
-
|
|
39
|
+
// The card that earns the most, even when a welcome bonus would take the
|
|
40
|
+
// pick for one purchase: the sheet is for the whole quarter.
|
|
41
|
+
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
42
|
+
ranked.push({ label: item.label, cards: rec.ranked });
|
|
34
43
|
return {
|
|
35
44
|
label: item.label,
|
|
36
45
|
cardId: top?.cardId ?? null,
|
|
@@ -40,8 +49,44 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
40
49
|
note: top?.warnings[0] ?? null,
|
|
41
50
|
};
|
|
42
51
|
});
|
|
52
|
+
const bonuses = wallet.cards.flatMap((held) => {
|
|
53
|
+
const status = bonusStatus(catalog, wallet, held, date);
|
|
54
|
+
if (status?.state !== 'open')
|
|
55
|
+
return [];
|
|
56
|
+
const name = held.checkout_label ??
|
|
57
|
+
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
58
|
+
held.id;
|
|
59
|
+
// Rows where the bonus card matches the row's card, or is close to it
|
|
60
|
+
// (as single answers mention it).
|
|
61
|
+
const matches = [];
|
|
62
|
+
const near = [];
|
|
63
|
+
for (const { label, cards } of ranked) {
|
|
64
|
+
const best = cards[0]?.effectiveReturnPct;
|
|
65
|
+
const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
|
|
66
|
+
if (best === undefined || own === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (best - own < EPSILON)
|
|
69
|
+
matches.push(label.toLowerCase());
|
|
70
|
+
else if (isClose(own, best))
|
|
71
|
+
near.push(label.toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
const where = [
|
|
74
|
+
...(matches.length > 0
|
|
75
|
+
? [`matches the listed card on ${listed(matches)}`]
|
|
76
|
+
: []),
|
|
77
|
+
...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
|
|
78
|
+
];
|
|
79
|
+
return [
|
|
80
|
+
`Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
81
|
+
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
82
|
+
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
83
|
+
];
|
|
84
|
+
});
|
|
43
85
|
const notes = [
|
|
44
|
-
...new Set(
|
|
86
|
+
...new Set([
|
|
87
|
+
...bonuses,
|
|
88
|
+
...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text)),
|
|
89
|
+
]),
|
|
45
90
|
];
|
|
46
91
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
47
92
|
return {
|
|
@@ -55,14 +100,12 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
55
100
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
56
101
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
57
102
|
// costs or needs.
|
|
58
|
-
|
|
103
|
+
const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
104
|
+
export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
|
|
59
105
|
const rows = items.map((item) => {
|
|
60
106
|
const top = exploreCards(catalog, 'merchant' in item
|
|
61
107
|
? {
|
|
62
|
-
merchant: catalog
|
|
63
|
-
?.name ??
|
|
64
|
-
catalog.merchants.overrides.find((m) => m.id === item.merchant)
|
|
65
|
-
?.name ??
|
|
108
|
+
merchant: resolveMerchantById(catalog, item.merchant, null)?.resolvedName ??
|
|
66
109
|
item.label,
|
|
67
110
|
category: null,
|
|
68
111
|
amount: null,
|
|
@@ -76,11 +119,7 @@ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_IT
|
|
|
76
119
|
channel: null,
|
|
77
120
|
date,
|
|
78
121
|
}, options).ranked[0];
|
|
79
|
-
const fee = top
|
|
80
|
-
? null
|
|
81
|
-
: top.annualFee === 0
|
|
82
|
-
? 'No annual fee.'
|
|
83
|
-
: `$${String(top.annualFee)} annual fee.`;
|
|
122
|
+
const fee = top ? `${capitalized(feeText(top))}.` : null;
|
|
84
123
|
return {
|
|
85
124
|
label: item.label,
|
|
86
125
|
cardId: top?.cardId ?? null,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Returns closer than this are equal: floating-point noise, not a difference.
|
|
2
|
+
export const EPSILON = 1e-9;
|
|
3
|
+
// A card close to the best one: within a percentage point, and at least
|
|
4
|
+
// three quarters of its rate. Answers and cheat sheets mention a welcome
|
|
5
|
+
// bonus card when it's this close.
|
|
6
|
+
export function isClose(own, best) {
|
|
7
|
+
return best - own <= 1 && own >= best * 0.75;
|
|
8
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
+
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
3
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
4
5
|
import { recommend } from "./recommend.js";
|
|
5
6
|
import { rotationGap } from "./rotation.js";
|
|
6
7
|
import { membershipsUsed } from "./setup.js";
|
|
7
|
-
import {
|
|
8
|
+
import { optionName } from "./wallet-summary.js";
|
|
9
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
8
10
|
import { cardValuation } from "./valuation.js";
|
|
9
|
-
|
|
11
|
+
import { EPSILON } from "./compare.js";
|
|
10
12
|
export const ALL_PERSONAL_CARDS = {
|
|
11
13
|
kind: 'any',
|
|
12
14
|
business: false,
|
|
@@ -47,18 +49,6 @@ export function idealHolding(card, date, picks = {}) {
|
|
|
47
49
|
: {}),
|
|
48
50
|
};
|
|
49
51
|
}
|
|
50
|
-
const PICK_PERIODS = {
|
|
51
|
-
month: 'set each month',
|
|
52
|
-
quarter: 'picked each quarter',
|
|
53
|
-
calendar_year: 'picked each year',
|
|
54
|
-
until_changed: 'until you change it',
|
|
55
|
-
};
|
|
56
|
-
const CAP_PERIODS = {
|
|
57
|
-
month: 'a month',
|
|
58
|
-
quarter: 'a quarter',
|
|
59
|
-
calendar_year: 'a calendar year',
|
|
60
|
-
cardmember_year: 'a cardmember year',
|
|
61
|
-
};
|
|
62
52
|
const plainWords = (s) => s
|
|
63
53
|
.toLowerCase()
|
|
64
54
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
@@ -147,17 +137,17 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
147
137
|
: null,
|
|
148
138
|
};
|
|
149
139
|
}
|
|
150
|
-
function conditionsText(catalog, r) {
|
|
140
|
+
function conditionsText(catalog, card, r) {
|
|
151
141
|
const conditions = [];
|
|
152
142
|
if (r.pick) {
|
|
153
|
-
conditions.push(`Needs
|
|
143
|
+
conditions.push(`Needs ${r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or ')} as its ${choiceName(r.pick.choice)} pick (${r.pick.period === 'until_changed' ? '' : 'picked '}${PICK_PERIODS[r.pick.period]}).`);
|
|
154
144
|
}
|
|
155
145
|
if (r.activation !== null)
|
|
156
|
-
conditions.push(
|
|
146
|
+
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
157
147
|
if (r.membership !== null)
|
|
158
|
-
conditions.push(`
|
|
148
|
+
conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
|
|
159
149
|
if (r.cap) {
|
|
160
|
-
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]
|
|
150
|
+
conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
|
|
161
151
|
}
|
|
162
152
|
return conditions;
|
|
163
153
|
}
|
|
@@ -206,6 +196,11 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
206
196
|
};
|
|
207
197
|
const ranked = [];
|
|
208
198
|
const excluded = [];
|
|
199
|
+
// A Costco purchase means a Costco member: no membership to point out.
|
|
200
|
+
const paid = paidMemberships(catalog, {
|
|
201
|
+
memberships: options.memberships,
|
|
202
|
+
merchantIds: [merchant.merchantId],
|
|
203
|
+
});
|
|
209
204
|
for (const card of catalog.cards) {
|
|
210
205
|
if (!matchesFilters(card, options.filters))
|
|
211
206
|
continue;
|
|
@@ -236,7 +231,10 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
236
231
|
firstYearFee: card.first_year_fee ?? null,
|
|
237
232
|
picks,
|
|
238
233
|
requirements,
|
|
239
|
-
conditions:
|
|
234
|
+
conditions: [
|
|
235
|
+
...conditionsText(catalog, card, requirements),
|
|
236
|
+
...membershipNeeded(catalog, card, paid),
|
|
237
|
+
],
|
|
240
238
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
241
239
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
242
240
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
}
|
|
18
|
+
// Memberships the person already pays for: the ones they've said, the ones a
|
|
19
|
+
// card they hold needs, and the ones their spending shows (only members shop
|
|
20
|
+
// at Costco).
|
|
21
|
+
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
22
|
+
const paid = new Set(memberships);
|
|
23
|
+
for (const card of catalog.cards) {
|
|
24
|
+
const id = card.requires_membership;
|
|
25
|
+
if (id !== undefined && heldCards.includes(card.id))
|
|
26
|
+
paid.add(id);
|
|
27
|
+
}
|
|
28
|
+
for (const m of catalog.memberships)
|
|
29
|
+
if (m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
30
|
+
paid.add(m.id);
|
|
31
|
+
return paid;
|
|
32
|
+
}
|
|
33
|
+
// What these cards' memberships add, once each, leaving out the paid ones.
|
|
34
|
+
export function membershipCosts(catalog, cards, paid) {
|
|
35
|
+
const costs = new Map();
|
|
36
|
+
for (const card of cards) {
|
|
37
|
+
const m = card.requires_membership === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: membership(catalog, card.requires_membership);
|
|
40
|
+
if (!m || paid.has(m.id))
|
|
41
|
+
continue;
|
|
42
|
+
const cost = costs.get(m.id) ?? {
|
|
43
|
+
id: m.id,
|
|
44
|
+
name: m.name,
|
|
45
|
+
annualFee: m.annual_fee ?? 0,
|
|
46
|
+
cardIds: [],
|
|
47
|
+
};
|
|
48
|
+
cost.cardIds.push(card.id);
|
|
49
|
+
costs.set(m.id, cost);
|
|
50
|
+
}
|
|
51
|
+
return [...costs.values()];
|
|
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
|
+
}
|
|
61
|
+
// The card's membership as a condition, unless it's paid for.
|
|
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)}.`]
|
|
66
|
+
: [];
|
|
67
|
+
}
|
|
68
|
+
export function membershipNote(catalog, m) {
|
|
69
|
+
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
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.`;
|
|
71
|
+
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
import { bonusDollars } from "./bonus.js";
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
|
-
import {
|
|
3
|
+
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
|
+
import { money, roundCents } from "./text.js";
|
|
4
5
|
import { cardValuation } from "./valuation.js";
|
|
6
|
+
function ineligibleText(catalog, wallet, notWith) {
|
|
7
|
+
const held = wallet.cards
|
|
8
|
+
.filter((c) => notWith.includes(c.id))
|
|
9
|
+
.map((c) => c.checkout_label ??
|
|
10
|
+
catalog.cards.find((k) => k.id === c.id)?.name ??
|
|
11
|
+
c.id);
|
|
12
|
+
return held.length === 0
|
|
13
|
+
? null
|
|
14
|
+
: `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
|
|
15
|
+
}
|
|
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
|
+
}
|
|
5
28
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
6
29
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
30
|
+
const paid = paidMemberships(catalog, {
|
|
31
|
+
memberships: wallet.memberships,
|
|
32
|
+
heldCards: [...held],
|
|
33
|
+
});
|
|
7
34
|
const offers = [];
|
|
8
35
|
const withoutOffer = [];
|
|
9
36
|
for (const card of catalog.cards) {
|
|
@@ -22,6 +49,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
22
49
|
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
23
50
|
0;
|
|
24
51
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
52
|
+
const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
|
|
25
53
|
offers.push({
|
|
26
54
|
cardId: card.id,
|
|
27
55
|
cardName: card.name,
|
|
@@ -39,17 +67,21 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
39
67
|
annualFee: card.annual_fee ?? null,
|
|
40
68
|
firstYearFee,
|
|
41
69
|
bonusValue: roundCents(value),
|
|
42
|
-
|
|
70
|
+
membershipFee,
|
|
71
|
+
ineligible: ineligibleText(catalog, wallet, offer.not_with ?? []),
|
|
72
|
+
firstYearValue: roundCents(value - (firstYearFee ?? 0) - membershipFee),
|
|
43
73
|
held: held.has(card.id),
|
|
44
74
|
lastVerified: card.last_verified,
|
|
45
75
|
verifiedBy: card.verified_by,
|
|
46
76
|
});
|
|
47
77
|
}
|
|
48
|
-
offers.sort((a, b) =>
|
|
78
|
+
offers.sort((a, b) => Number(a.ineligible !== null) - Number(b.ineligible !== null) ||
|
|
79
|
+
b.firstYearValue - a.firstYearValue ||
|
|
80
|
+
a.cardId.localeCompare(b.cardId));
|
|
49
81
|
const notes = [
|
|
50
82
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
51
|
-
|
|
52
|
-
"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.",
|
|
53
85
|
];
|
|
54
86
|
return { offers, withoutOffer, notes };
|
|
55
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
|
}
|