earnrate 0.1.1 → 0.1.3
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 +28 -9
- package/data/CHANGELOG.md +17 -0
- package/data/VERSION +1 -1
- package/data/cards/capital-one-savor.yaml +1 -1
- package/data/cards/capital-one-venture-x.yaml +1 -1
- package/data/cards/capital-one-venture.yaml +1 -1
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/data/cards/citi-custom-cash.yaml +4 -3
- package/data/categories.yaml +24 -0
- package/dist/cli/cards.js +34 -0
- package/dist/cli/explore.js +364 -0
- package/dist/cli/run.js +23 -2
- package/dist/cli/wallet.js +194 -0
- package/dist/data/integrity.js +14 -0
- package/dist/data/wallet-edit.js +162 -0
- package/dist/data/wallet-setup.js +60 -0
- package/dist/engine/explore.js +292 -0
- package/dist/engine/recommend.js +15 -6
- package/dist/engine/rotation.js +16 -0
- package/dist/engine/setup.js +168 -0
- package/dist/engine/simulate.js +309 -0
- package/dist/engine/wallet-change.js +179 -0
- package/dist/engine/wallet-summary.js +2 -0
- package/dist/mcp/schemas.js +116 -0
- package/dist/mcp/server.js +309 -3
- package/dist/schema/card.js +9 -1
- package/dist/schema/category.js +2 -0
- package/package.json +7 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { daysBetween } from "./periods.js";
|
|
3
|
+
import { rotationGap } from "./rotation.js";
|
|
4
|
+
// Rules that start within this many days are worth activating now.
|
|
5
|
+
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
6
|
+
const ISSUER_NICKNAMES = {
|
|
7
|
+
amex: 'american express',
|
|
8
|
+
'bank-of-america': 'bofa boa bankofamerica',
|
|
9
|
+
'capital-one': 'capitalone',
|
|
10
|
+
citi: 'citibank',
|
|
11
|
+
usbank: 'us bank',
|
|
12
|
+
'wells-fargo': 'wf wellsfargo',
|
|
13
|
+
'td-bank': 'td',
|
|
14
|
+
column: 'bilt',
|
|
15
|
+
};
|
|
16
|
+
const words = (s) => s
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/\+/g, ' plus ')
|
|
19
|
+
.replace(/&/g, ' and ')
|
|
20
|
+
.split(/[^a-z0-9]+/)
|
|
21
|
+
.filter((w) => w.length > 0);
|
|
22
|
+
function editDistance(a, b) {
|
|
23
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
24
|
+
for (let i = 1; i <= a.length; i++) {
|
|
25
|
+
let diagonal = row[0] ?? 0;
|
|
26
|
+
row[0] = i;
|
|
27
|
+
for (let j = 1; j <= b.length; j++) {
|
|
28
|
+
const above = row[j] ?? 0;
|
|
29
|
+
row[j] = Math.min(above + 1, (row[j - 1] ?? 0) + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
30
|
+
diagonal = above;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return row[b.length] ?? 0;
|
|
34
|
+
}
|
|
35
|
+
function matcher(card) {
|
|
36
|
+
const haystack = [
|
|
37
|
+
...words(card.id),
|
|
38
|
+
...words(card.name),
|
|
39
|
+
...words(card.issuer),
|
|
40
|
+
...words(ISSUER_NICKNAMES[card.issuer] ?? ''),
|
|
41
|
+
];
|
|
42
|
+
const compact = haystack.join('');
|
|
43
|
+
return (token) => haystack.some((w) => w.startsWith(token)) ||
|
|
44
|
+
(token.length >= 4 && compact.includes(token)) ||
|
|
45
|
+
(token.length >= 5 &&
|
|
46
|
+
haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
|
|
47
|
+
}
|
|
48
|
+
function needsAnniversary(card) {
|
|
49
|
+
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
50
|
+
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
|
|
51
|
+
}
|
|
52
|
+
function activationRules(card, today) {
|
|
53
|
+
return card.rules
|
|
54
|
+
.filter((r) => r.requires?.activation === true &&
|
|
55
|
+
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
56
|
+
(r.valid_from === undefined ||
|
|
57
|
+
daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
|
|
58
|
+
.map((r) => r.id);
|
|
59
|
+
}
|
|
60
|
+
export function membershipsUsed(card) {
|
|
61
|
+
return [
|
|
62
|
+
...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
export function knownMemberships(catalog) {
|
|
66
|
+
return [...new Set(catalog.cards.flatMap(membershipsUsed))].sort();
|
|
67
|
+
}
|
|
68
|
+
export function cardInfo(card, wallet, today) {
|
|
69
|
+
return {
|
|
70
|
+
id: card.id,
|
|
71
|
+
name: card.name,
|
|
72
|
+
issuer: card.issuer,
|
|
73
|
+
network: card.network,
|
|
74
|
+
currency: card.currency,
|
|
75
|
+
annualFee: card.annual_fee ?? null,
|
|
76
|
+
business: card.business,
|
|
77
|
+
onlyAt: card.only_at ?? null,
|
|
78
|
+
held: wallet?.cards.some((c) => c.id === card.id) ?? false,
|
|
79
|
+
choices: card.choices.map((c) => ({
|
|
80
|
+
id: c.id,
|
|
81
|
+
pick: c.pick,
|
|
82
|
+
period: c.period,
|
|
83
|
+
options: Object.keys(c.options),
|
|
84
|
+
default: c.default ?? null,
|
|
85
|
+
})),
|
|
86
|
+
memberships: membershipsUsed(card),
|
|
87
|
+
activation: activationRules(card, today),
|
|
88
|
+
needsAnniversary: needsAnniversary(card),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Every word of the query has to match a word of the card's id, name or
|
|
92
|
+
// issuer (as a prefix, or with a typo or two in a long word). With no full
|
|
93
|
+
// match, the cards matching the most words come back instead.
|
|
94
|
+
export function searchCards(catalog, query, wallet, today) {
|
|
95
|
+
const tokens = words(query);
|
|
96
|
+
const scored = catalog.cards.map((card) => {
|
|
97
|
+
const matches = matcher(card);
|
|
98
|
+
return { card, score: tokens.filter((t) => matches(t)).length };
|
|
99
|
+
});
|
|
100
|
+
const byShortestId = (a, b) => words(a.card.id).length - words(b.card.id).length ||
|
|
101
|
+
a.card.id.localeCompare(b.card.id);
|
|
102
|
+
const full = scored.filter((s) => s.score === tokens.length);
|
|
103
|
+
const best = Math.max(0, ...scored.map((s) => s.score));
|
|
104
|
+
const exact = full.length > 0;
|
|
105
|
+
const chosen = exact
|
|
106
|
+
? full.sort(byShortestId)
|
|
107
|
+
: best === 0
|
|
108
|
+
? []
|
|
109
|
+
: scored
|
|
110
|
+
.filter((s) => s.score === best)
|
|
111
|
+
.sort(byShortestId)
|
|
112
|
+
.slice(0, 5);
|
|
113
|
+
return {
|
|
114
|
+
cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
|
|
115
|
+
exact,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const list = (ids) => ids.join(', ');
|
|
119
|
+
// What a card in the wallet still needs before its answers are right.
|
|
120
|
+
export function setupNotes(catalog, wallet, held, today) {
|
|
121
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
122
|
+
if (!card)
|
|
123
|
+
return [];
|
|
124
|
+
const notes = [];
|
|
125
|
+
const name = held.checkout_label ?? card.name;
|
|
126
|
+
const { picked, staleChoice } = pickedCategories(card, held, today);
|
|
127
|
+
for (const choice of card.choices) {
|
|
128
|
+
const current = held.chosen?.picks[choice.id] !== undefined &&
|
|
129
|
+
(staleChoice === null || choice.period === 'until_changed');
|
|
130
|
+
if (current)
|
|
131
|
+
continue;
|
|
132
|
+
const options = list(Object.keys(choice.options));
|
|
133
|
+
notes.push({
|
|
134
|
+
kind: 'pick',
|
|
135
|
+
text: choice.default && picked.has(choice.id)
|
|
136
|
+
? `${name}'s ${choice.id} pick is assumed to be ${list(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
137
|
+
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m));
|
|
141
|
+
if (missing.length > 0) {
|
|
142
|
+
notes.push({
|
|
143
|
+
kind: 'membership',
|
|
144
|
+
text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const toActivate = activationRules(card, today).filter((id) => !held.activated.includes(id));
|
|
148
|
+
if (toActivate.length > 0) {
|
|
149
|
+
notes.push({
|
|
150
|
+
kind: 'activation',
|
|
151
|
+
text: `${name} has rates that apply only once activated with the issuer: ${list(toActivate)}. Mark the ones already activated.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (needsAnniversary(card) && held.anniversary === undefined) {
|
|
155
|
+
notes.push({
|
|
156
|
+
kind: 'anniversary',
|
|
157
|
+
text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const gap = rotationGap(card, today);
|
|
161
|
+
if (gap !== null) {
|
|
162
|
+
notes.push({
|
|
163
|
+
kind: 'rotation',
|
|
164
|
+
text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return notes;
|
|
168
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
+
import { categoryResolution, choicePeriod, idealHolding, matchesFilters, } from "./explore.js";
|
|
4
|
+
import { periodKey } from "./periods.js";
|
|
5
|
+
import { applyBooking, capBooking } from "./record.js";
|
|
6
|
+
import { rotationGap } from "./rotation.js";
|
|
7
|
+
import { cardValuation } from "./valuation.js";
|
|
8
|
+
const EPSILON = 1e-9;
|
|
9
|
+
const MONTHS = 12;
|
|
10
|
+
const SHORTLIST = 8; // best single cards considered for pairs
|
|
11
|
+
const PER_LINE = 2; // plus the best cards for each spending line
|
|
12
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
13
|
+
export function resolveSpendLines(catalog, lines) {
|
|
14
|
+
return lines
|
|
15
|
+
.filter((l) => l.monthly > 0)
|
|
16
|
+
.map((l) => {
|
|
17
|
+
const merchant = l.merchant !== null
|
|
18
|
+
? resolveMerchant(catalog, l.merchant, null)
|
|
19
|
+
: categoryResolution(catalog, l.category ?? 'other', null);
|
|
20
|
+
return { label: merchant.resolvedName, monthly: l.monthly, merchant };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function monthDates(start) {
|
|
24
|
+
const year = Number(start.slice(0, 4));
|
|
25
|
+
const month = Number(start.slice(5, 7)) - 1;
|
|
26
|
+
return Array.from({ length: MONTHS }, (_, i) => {
|
|
27
|
+
const y = year + Math.floor((month + i) / 12);
|
|
28
|
+
const m = ((month + i) % 12) + 1;
|
|
29
|
+
return `${String(y)}-${String(m).padStart(2, '0')}-15`;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// Picks carry over from period to period, so a quarter's picks hold all year.
|
|
33
|
+
function withCurrentPicks(card, held, date) {
|
|
34
|
+
if (!held.chosen)
|
|
35
|
+
return held;
|
|
36
|
+
return {
|
|
37
|
+
...held,
|
|
38
|
+
chosen: {
|
|
39
|
+
...held.chosen,
|
|
40
|
+
period: periodKey(choicePeriod(card), date, null) ?? '',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function usableAt(card, merchant) {
|
|
45
|
+
if (card.only_at)
|
|
46
|
+
return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
|
|
47
|
+
return (card.network === 'store' ||
|
|
48
|
+
merchant.accepts === null ||
|
|
49
|
+
merchant.accepts.includes(card.network));
|
|
50
|
+
}
|
|
51
|
+
// Spends `lines` every month for a year. Each dollar goes to the card that
|
|
52
|
+
// earns the most on it right then, so spend moves to another card once a
|
|
53
|
+
// cap runs out.
|
|
54
|
+
export function simulateWallet(catalog, wallet, lines, start) {
|
|
55
|
+
const cards = wallet.cards.map((held) => {
|
|
56
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
57
|
+
if (!card)
|
|
58
|
+
throw new Error(`${held.id} isn't in the card catalog`);
|
|
59
|
+
return { card, cents: cardValuation(catalog, wallet, card).centsPerUnit };
|
|
60
|
+
});
|
|
61
|
+
let state = wallet;
|
|
62
|
+
const results = lines.map(() => ({
|
|
63
|
+
spend: 0,
|
|
64
|
+
earned: 0,
|
|
65
|
+
byCard: new Map(),
|
|
66
|
+
}));
|
|
67
|
+
for (const date of monthDates(start)) {
|
|
68
|
+
lines.forEach((line, i) => {
|
|
69
|
+
const result = results[i];
|
|
70
|
+
if (!result)
|
|
71
|
+
return;
|
|
72
|
+
let left = line.monthly;
|
|
73
|
+
result.spend += left;
|
|
74
|
+
for (let guard = 0; left > EPSILON && guard < 20; guard++) {
|
|
75
|
+
let pick = null;
|
|
76
|
+
for (const { card, cents } of cards) {
|
|
77
|
+
if (!usableAt(card, line.merchant))
|
|
78
|
+
continue;
|
|
79
|
+
const raw = state.cards.find((h) => h.id === card.id);
|
|
80
|
+
if (!raw)
|
|
81
|
+
continue;
|
|
82
|
+
const held = withCurrentPicks(card, raw, date);
|
|
83
|
+
const ctx = purchaseContext(catalog, state, line.merchant, line.merchant.category, { amount: null, date, country: 'US' });
|
|
84
|
+
const { best } = evaluateCard(card, held, ctx);
|
|
85
|
+
const perDollar = (best.rate * cents) / 100;
|
|
86
|
+
const room = best.cap && best.cap.remaining > 0 ? best.cap.remaining : Infinity;
|
|
87
|
+
if (!pick || perDollar > pick.perDollar + EPSILON)
|
|
88
|
+
pick = { card, perDollar, room };
|
|
89
|
+
}
|
|
90
|
+
if (!pick)
|
|
91
|
+
break;
|
|
92
|
+
const chosen = pick;
|
|
93
|
+
const amount = Math.min(left, chosen.room);
|
|
94
|
+
result.earned += amount * chosen.perDollar;
|
|
95
|
+
result.byCard.set(chosen.card.id, (result.byCard.get(chosen.card.id) ?? 0) + amount);
|
|
96
|
+
const booking = capBooking(catalog, {
|
|
97
|
+
...state,
|
|
98
|
+
cards: state.cards.map((h) => h.id === chosen.card.id
|
|
99
|
+
? withCurrentPicks(chosen.card, h, date)
|
|
100
|
+
: h),
|
|
101
|
+
}, {
|
|
102
|
+
cardId: chosen.card.id,
|
|
103
|
+
merchant: line.label,
|
|
104
|
+
amount,
|
|
105
|
+
date,
|
|
106
|
+
channel: line.merchant.channel,
|
|
107
|
+
}, line.merchant);
|
|
108
|
+
if (booking)
|
|
109
|
+
state = applyBooking(state, booking);
|
|
110
|
+
left -= amount;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const lineResults = lines.map((line, i) => {
|
|
115
|
+
const r = results[i] ?? {
|
|
116
|
+
spend: 0,
|
|
117
|
+
earned: 0,
|
|
118
|
+
byCard: new Map(),
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
label: line.label,
|
|
122
|
+
spend: round(r.spend),
|
|
123
|
+
earned: round(r.earned),
|
|
124
|
+
returnPct: r.spend > 0 ? (r.earned / r.spend) * 100 : 0,
|
|
125
|
+
cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
const rewards = round(lineResults.reduce((sum, l) => sum + l.earned, 0));
|
|
129
|
+
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
130
|
+
const notes = [];
|
|
131
|
+
const dates = monthDates(start);
|
|
132
|
+
for (const { card } of cards) {
|
|
133
|
+
const gaps = [
|
|
134
|
+
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
135
|
+
];
|
|
136
|
+
if (gaps.length > 0)
|
|
137
|
+
notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
138
|
+
if (card.annual_fee === undefined)
|
|
139
|
+
notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
|
|
140
|
+
else if (card.first_year_fee !== undefined &&
|
|
141
|
+
card.first_year_fee !== card.annual_fee)
|
|
142
|
+
notes.push(`${card.name}'s fee is $${String(card.first_year_fee)} the first year; the yearly figure uses the $${String(card.annual_fee)} that follows.`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
cards: wallet.cards.map((h) => ({
|
|
146
|
+
cardId: h.id,
|
|
147
|
+
picks: h.chosen?.picks ?? {},
|
|
148
|
+
})),
|
|
149
|
+
spend: round(lineResults.reduce((sum, l) => sum + l.spend, 0)),
|
|
150
|
+
rewards,
|
|
151
|
+
annualFees,
|
|
152
|
+
net: round(rewards - annualFees),
|
|
153
|
+
lines: lineResults,
|
|
154
|
+
notes,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function combinations(items, size) {
|
|
158
|
+
if (size === 0)
|
|
159
|
+
return [[]];
|
|
160
|
+
if (items.length < size)
|
|
161
|
+
return [items];
|
|
162
|
+
const [first, ...rest] = items;
|
|
163
|
+
if (first === undefined)
|
|
164
|
+
return [[]];
|
|
165
|
+
return [
|
|
166
|
+
...combinations(rest, size - 1).map((c) => [first, ...c]),
|
|
167
|
+
...combinations(rest, size),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
// Pick sets worth trying: only options that raise the card's rate on one of
|
|
171
|
+
// the lines, combined up to the number of picks the card allows.
|
|
172
|
+
function candidatePicks(catalog, card, lines, memberships, date) {
|
|
173
|
+
let sets = [{}];
|
|
174
|
+
for (const choice of card.choices) {
|
|
175
|
+
const useful = Object.keys(choice.options).filter((option) => {
|
|
176
|
+
const plain = idealHolding(card, date);
|
|
177
|
+
const picked = idealHolding(card, date, { [choice.id]: [option] });
|
|
178
|
+
return lines.some((line) => {
|
|
179
|
+
const ctx = purchaseContext(catalog, { valuations: {}, memberships, cards: [picked] }, line.merchant, line.merchant.category, { amount: null, date, country: 'US' });
|
|
180
|
+
return (evaluateCard(card, picked, ctx).best.rate >
|
|
181
|
+
evaluateCard(card, plain, ctx).best.rate + EPSILON);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
if (useful.length === 0)
|
|
185
|
+
continue;
|
|
186
|
+
const combos = combinations(useful, Math.min(choice.pick, useful.length));
|
|
187
|
+
sets = sets.flatMap((s) => combos.map((c) => ({ ...s, [choice.id]: c })));
|
|
188
|
+
}
|
|
189
|
+
return sets;
|
|
190
|
+
}
|
|
191
|
+
function walletWith(base, card, picks, options) {
|
|
192
|
+
return {
|
|
193
|
+
valuations: options.valuations,
|
|
194
|
+
memberships: options.memberships,
|
|
195
|
+
cards: [...base, idealHolding(card, options.start, picks)],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// The best picks for `card` alongside the cards already in `base`.
|
|
199
|
+
function bestSimulation(catalog, base, card, lines, options) {
|
|
200
|
+
let best = null;
|
|
201
|
+
for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
|
|
202
|
+
const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
|
|
203
|
+
if (!best || sim.net > best.net + EPSILON)
|
|
204
|
+
best = sim;
|
|
205
|
+
}
|
|
206
|
+
if (!best)
|
|
207
|
+
throw new Error(`no simulation for ${card.id}`);
|
|
208
|
+
return best;
|
|
209
|
+
}
|
|
210
|
+
const byNet = (a, b) => b.net - a.net || a.annualFees - b.annualFees;
|
|
211
|
+
// Pairs that tie and share their first card differ only in an
|
|
212
|
+
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
213
|
+
function foldTies(pairs) {
|
|
214
|
+
const folded = [];
|
|
215
|
+
for (const pair of pairs) {
|
|
216
|
+
const [a, b] = pair.cards;
|
|
217
|
+
const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
|
|
218
|
+
f.annualFees === pair.annualFees &&
|
|
219
|
+
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
220
|
+
if (!twin) {
|
|
221
|
+
folded.push({ ...pair, alternatives: [] });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const shared = new Set(twin.cards.map((c) => c.cardId));
|
|
225
|
+
const other = [a, b].find((c) => c && !shared.has(c.cardId));
|
|
226
|
+
if (other)
|
|
227
|
+
twin.alternatives.push(other.cardId);
|
|
228
|
+
}
|
|
229
|
+
return folded;
|
|
230
|
+
}
|
|
231
|
+
export function simulateCatalog(catalog, lines, options) {
|
|
232
|
+
const eligible = catalog.cards.filter((c) => matchesFilters(c, options.filters));
|
|
233
|
+
const singles = eligible
|
|
234
|
+
.map((card) => bestSimulation(catalog, [], card, lines, options))
|
|
235
|
+
.sort(byNet);
|
|
236
|
+
const shortlist = new Set(singles.slice(0, SHORTLIST).map((s) => s.cards[0]?.cardId));
|
|
237
|
+
lines.forEach((line) => {
|
|
238
|
+
const best = [...singles].sort((a, b) => (b.lines.find((l) => l.label === line.label)?.earned ?? 0) -
|
|
239
|
+
(a.lines.find((l) => l.label === line.label)?.earned ?? 0));
|
|
240
|
+
for (const s of best.slice(0, PER_LINE))
|
|
241
|
+
shortlist.add(s.cards[0]?.cardId);
|
|
242
|
+
});
|
|
243
|
+
// A card whose points pool into a shortlisted card's is worth more beside it.
|
|
244
|
+
for (const card of eligible) {
|
|
245
|
+
if (card.pools_into?.when_wallet_has.some((id) => shortlist.has(id)))
|
|
246
|
+
shortlist.add(card.id);
|
|
247
|
+
}
|
|
248
|
+
const finalists = eligible.filter((c) => shortlist.has(c.id));
|
|
249
|
+
const singleFor = new Map(singles.map((s) => [s.cards[0]?.cardId, s]));
|
|
250
|
+
const pairs = [];
|
|
251
|
+
finalists.forEach((first, i) => {
|
|
252
|
+
const firstPicks = singleFor.get(first.id)?.cards[0]?.picks ?? {};
|
|
253
|
+
for (const second of finalists.slice(i + 1)) {
|
|
254
|
+
const base = [idealHolding(first, options.start, firstPicks)];
|
|
255
|
+
pairs.push(bestSimulation(catalog, base, second, lines, options));
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
pairs.sort(byNet);
|
|
259
|
+
return { singles, pairs: foldTies(pairs).slice(0, 10) };
|
|
260
|
+
}
|
|
261
|
+
// The person's own wallet, set up as it would run all year: their picks carry
|
|
262
|
+
// over, rotating categories get activated, caps start fresh.
|
|
263
|
+
function projectedWallet(wallet, catalog, start) {
|
|
264
|
+
return {
|
|
265
|
+
...wallet,
|
|
266
|
+
cards: wallet.cards.map((held) => {
|
|
267
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
268
|
+
const ideal = card
|
|
269
|
+
? idealHolding(card, start, held.chosen?.picks ?? {})
|
|
270
|
+
: held;
|
|
271
|
+
return {
|
|
272
|
+
...ideal,
|
|
273
|
+
...(held.checkout_label !== undefined
|
|
274
|
+
? { checkout_label: held.checkout_label }
|
|
275
|
+
: {}),
|
|
276
|
+
...(held.anniversary !== undefined
|
|
277
|
+
? { anniversary: held.anniversary }
|
|
278
|
+
: {}),
|
|
279
|
+
};
|
|
280
|
+
}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
export function simulateAdditions(catalog, wallet, lines, options) {
|
|
284
|
+
const projected = projectedWallet(wallet, catalog, options.start);
|
|
285
|
+
const current = simulateWallet(catalog, projected, lines, options.start);
|
|
286
|
+
const held = new Set(wallet.cards.map((c) => c.id));
|
|
287
|
+
const additions = [];
|
|
288
|
+
for (const card of catalog.cards) {
|
|
289
|
+
if (held.has(card.id) || !matchesFilters(card, options.filters))
|
|
290
|
+
continue;
|
|
291
|
+
const simulation = bestSimulation(catalog, projected.cards, card, lines, {
|
|
292
|
+
...options,
|
|
293
|
+
memberships: wallet.memberships,
|
|
294
|
+
valuations: wallet.valuations,
|
|
295
|
+
});
|
|
296
|
+
const extraRewards = round(simulation.rewards - current.rewards);
|
|
297
|
+
const annualFee = card.annual_fee ?? 0;
|
|
298
|
+
additions.push({
|
|
299
|
+
cardId: card.id,
|
|
300
|
+
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
301
|
+
extraRewards,
|
|
302
|
+
annualFee,
|
|
303
|
+
gain: round(extraRewards - annualFee),
|
|
304
|
+
simulation,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
|
|
308
|
+
return { current, additions };
|
|
309
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { periodKey } from "./periods.js";
|
|
2
|
+
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
3
|
+
const failed = (error) => ({ ok: false, error });
|
|
4
|
+
const list = (ids) => ids.join(', ');
|
|
5
|
+
export function findCatalogCard(catalog, id, today) {
|
|
6
|
+
const card = catalog.cards.find((c) => c.id === id);
|
|
7
|
+
if (card)
|
|
8
|
+
return card;
|
|
9
|
+
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
10
|
+
return near.length > 0
|
|
11
|
+
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
12
|
+
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
13
|
+
}
|
|
14
|
+
export function isAnniversary(text) {
|
|
15
|
+
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
16
|
+
if (!m)
|
|
17
|
+
return false;
|
|
18
|
+
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
19
|
+
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
20
|
+
}
|
|
21
|
+
// The period key picks are filed under: the card's choice period, or for
|
|
22
|
+
// picks that carry over, the current year (it's never compared).
|
|
23
|
+
function pickPeriod(card, today) {
|
|
24
|
+
const periods = [
|
|
25
|
+
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
26
|
+
];
|
|
27
|
+
if (periods.length > 1)
|
|
28
|
+
return null;
|
|
29
|
+
return periodKey(periods[0] ?? 'calendar_year', today, null);
|
|
30
|
+
}
|
|
31
|
+
export function cardFields(card, held, input, today) {
|
|
32
|
+
const problems = [];
|
|
33
|
+
const fields = {};
|
|
34
|
+
if (input.checkoutLabel !== undefined) {
|
|
35
|
+
if (input.checkoutLabel.trim() === '')
|
|
36
|
+
problems.push('The checkout label is empty.');
|
|
37
|
+
// earnrate never stores card numbers; a last four is fine.
|
|
38
|
+
if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
|
|
39
|
+
problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
|
|
40
|
+
fields.checkout_label = input.checkoutLabel.trim();
|
|
41
|
+
}
|
|
42
|
+
if (input.anniversary !== undefined) {
|
|
43
|
+
if (!isAnniversary(input.anniversary)) {
|
|
44
|
+
problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
|
|
45
|
+
}
|
|
46
|
+
fields.anniversary = input.anniversary;
|
|
47
|
+
}
|
|
48
|
+
if (input.activated !== undefined) {
|
|
49
|
+
const needing = card.rules
|
|
50
|
+
.filter((r) => r.requires?.activation)
|
|
51
|
+
.map((r) => r.id);
|
|
52
|
+
for (const id of input.activated) {
|
|
53
|
+
if (!needing.includes(id)) {
|
|
54
|
+
problems.push(needing.length > 0
|
|
55
|
+
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
|
|
56
|
+
: `${card.id} has no rules that need activating.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
fields.activated = [...new Set(input.activated)];
|
|
60
|
+
}
|
|
61
|
+
if (input.picks !== undefined) {
|
|
62
|
+
for (const [choiceId, picks] of Object.entries(input.picks)) {
|
|
63
|
+
const choice = card.choices.find((c) => c.id === choiceId);
|
|
64
|
+
if (!choice) {
|
|
65
|
+
problems.push(card.choices.length > 0
|
|
66
|
+
? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
|
|
67
|
+
: `${card.id} has no categories to pick.`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const options = Object.keys(choice.options);
|
|
71
|
+
for (const p of picks) {
|
|
72
|
+
if (!options.includes(p)) {
|
|
73
|
+
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (picks.length === 0 || picks.length > choice.pick) {
|
|
77
|
+
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const period = pickPeriod(card, today);
|
|
81
|
+
if (period === null) {
|
|
82
|
+
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const kept = held?.chosen?.period === period ? held.chosen.picks : {};
|
|
86
|
+
fields.chosen = {
|
|
87
|
+
period,
|
|
88
|
+
picks: { ...kept, ...input.picks },
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return problems.length > 0 ? problems.join('\n') : fields;
|
|
93
|
+
}
|
|
94
|
+
export const newHolding = (id) => ({
|
|
95
|
+
id,
|
|
96
|
+
cap_usage: {},
|
|
97
|
+
activated: [],
|
|
98
|
+
});
|
|
99
|
+
export const withFields = (held, f) => ({
|
|
100
|
+
...held,
|
|
101
|
+
...(f.checkout_label === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { checkout_label: f.checkout_label }),
|
|
104
|
+
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
105
|
+
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
106
|
+
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
107
|
+
});
|
|
108
|
+
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
109
|
+
export function addToWallet(catalog, wallet, input, today) {
|
|
110
|
+
const card = findCatalogCard(catalog, input.id, today);
|
|
111
|
+
if (typeof card === 'string')
|
|
112
|
+
return failed(card);
|
|
113
|
+
if (wallet?.cards.some((c) => c.id === card.id)) {
|
|
114
|
+
return failed(`${card.id} is already in the wallet. Update it instead.`);
|
|
115
|
+
}
|
|
116
|
+
const fields = cardFields(card, null, input, today);
|
|
117
|
+
if (typeof fields === 'string')
|
|
118
|
+
return failed(fields);
|
|
119
|
+
const held = withFields(newHolding(card.id), fields);
|
|
120
|
+
const next = wallet
|
|
121
|
+
? { ...wallet, cards: [...wallet.cards, held] }
|
|
122
|
+
: { valuations: {}, memberships: [], cards: [held] };
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
wallet: next,
|
|
126
|
+
fields,
|
|
127
|
+
notes: setupNotes(catalog, next, held, today),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function updateInWallet(catalog, wallet, input, today) {
|
|
131
|
+
const held = wallet.cards.find((c) => c.id === input.id);
|
|
132
|
+
const card = catalog.cards.find((c) => c.id === input.id);
|
|
133
|
+
if (!held || !card) {
|
|
134
|
+
return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
135
|
+
}
|
|
136
|
+
const fields = cardFields(card, held, input, today);
|
|
137
|
+
if (typeof fields === 'string')
|
|
138
|
+
return failed(fields);
|
|
139
|
+
if (Object.keys(fields).length === 0)
|
|
140
|
+
return failed('Nothing to change.');
|
|
141
|
+
const updated = withFields(held, fields);
|
|
142
|
+
const next = {
|
|
143
|
+
...wallet,
|
|
144
|
+
cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
wallet: next,
|
|
149
|
+
fields,
|
|
150
|
+
notes: setupNotes(catalog, next, updated, today),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function removeFromWallet(wallet, id) {
|
|
154
|
+
if (!wallet.cards.some((c) => c.id === id)) {
|
|
155
|
+
return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
156
|
+
}
|
|
157
|
+
if (wallet.cards.length === 1) {
|
|
158
|
+
return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
163
|
+
fields: {},
|
|
164
|
+
notes: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function withMemberships(catalog, wallet, memberships) {
|
|
168
|
+
const known = knownMemberships(catalog);
|
|
169
|
+
const unknown = memberships.filter((m) => !known.includes(m));
|
|
170
|
+
if (unknown.length > 0) {
|
|
171
|
+
return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
wallet: { ...wallet, memberships: [...new Set(memberships)] },
|
|
176
|
+
fields: {},
|
|
177
|
+
notes: [],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { pickedCategories } from "./evaluate.js";
|
|
3
3
|
import { daysBetween, periodKey } from "./periods.js";
|
|
4
|
+
import { rotationGap } from "./rotation.js";
|
|
4
5
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
6
|
import { cardValuation } from "./valuation.js";
|
|
6
7
|
function capSummaries(card, held, today) {
|
|
@@ -54,6 +55,7 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
54
55
|
lastVerified: card.last_verified,
|
|
55
56
|
verifiedBy: card.verified_by,
|
|
56
57
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
58
|
+
rotationGap: rotationGap(card, today),
|
|
57
59
|
caps: capSummaries(card, held, today),
|
|
58
60
|
chosen: held.chosen
|
|
59
61
|
? {
|