earnrate 0.1.2 → 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 +17 -4
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/cards/citi-custom-cash.yaml +4 -3
- package/data/categories.yaml +24 -0
- package/dist/cli/explore.js +364 -0
- package/dist/cli/run.js +9 -0
- package/dist/data/wallet-setup.js +23 -165
- package/dist/engine/explore.js +292 -0
- package/dist/engine/recommend.js +7 -7
- package/dist/engine/setup.js +1 -1
- package/dist/engine/simulate.js +309 -0
- package/dist/engine/wallet-change.js +179 -0
- package/dist/mcp/schemas.js +70 -0
- package/dist/mcp/server.js +155 -5
- package/dist/schema/card.js +2 -0
- package/dist/schema/category.js +2 -0
- package/package.json +6 -3
|
@@ -1,107 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { knownMemberships, searchCards, setupNotes, } from "../engine/setup.js";
|
|
3
|
-
import { WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
|
|
1
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
4
2
|
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
|
|
5
3
|
const failed = (error) => ({ ok: false, error });
|
|
6
|
-
const list = (ids) => ids.join(', ');
|
|
7
|
-
function findCatalogCard(catalog, id, today) {
|
|
8
|
-
const card = catalog.cards.find((c) => c.id === id);
|
|
9
|
-
if (card)
|
|
10
|
-
return card;
|
|
11
|
-
const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
|
|
12
|
-
return near.length > 0
|
|
13
|
-
? `No card '${id}' in earnrate. Closest: ${list(near)}.`
|
|
14
|
-
: `No card '${id}' in earnrate. Search the catalog to find its id.`;
|
|
15
|
-
}
|
|
16
|
-
function isAnniversary(text) {
|
|
17
|
-
const m = /^(\d{2})-(\d{2})$/.exec(text);
|
|
18
|
-
if (!m)
|
|
19
|
-
return false;
|
|
20
|
-
const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
|
|
21
|
-
return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
|
|
22
|
-
}
|
|
23
|
-
// The period key picks are filed under: the card's choice period, or for
|
|
24
|
-
// picks that carry over, the current year (it's never compared).
|
|
25
|
-
function choicePeriod(card, today) {
|
|
26
|
-
const periods = [
|
|
27
|
-
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
28
|
-
];
|
|
29
|
-
if (periods.length > 1)
|
|
30
|
-
return null;
|
|
31
|
-
return periodKey(periods[0] ?? 'calendar_year', today, null);
|
|
32
|
-
}
|
|
33
|
-
function fieldsFor(card, held, input, today) {
|
|
34
|
-
const problems = [];
|
|
35
|
-
const fields = {};
|
|
36
|
-
if (input.checkoutLabel !== undefined) {
|
|
37
|
-
if (input.checkoutLabel.trim() === '')
|
|
38
|
-
problems.push('The checkout label is empty.');
|
|
39
|
-
// earnrate never stores card numbers; a last four is fine.
|
|
40
|
-
if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
|
|
41
|
-
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".');
|
|
42
|
-
fields.checkout_label = input.checkoutLabel.trim();
|
|
43
|
-
}
|
|
44
|
-
if (input.anniversary !== undefined) {
|
|
45
|
-
if (!isAnniversary(input.anniversary)) {
|
|
46
|
-
problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
|
|
47
|
-
}
|
|
48
|
-
fields.anniversary = input.anniversary;
|
|
49
|
-
}
|
|
50
|
-
if (input.activated !== undefined) {
|
|
51
|
-
const needing = card.rules
|
|
52
|
-
.filter((r) => r.requires?.activation)
|
|
53
|
-
.map((r) => r.id);
|
|
54
|
-
for (const id of input.activated) {
|
|
55
|
-
if (!needing.includes(id)) {
|
|
56
|
-
problems.push(needing.length > 0
|
|
57
|
-
? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
|
|
58
|
-
: `${card.id} has no rules that need activating.`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
fields.activated = [...new Set(input.activated)];
|
|
62
|
-
}
|
|
63
|
-
if (input.picks !== undefined) {
|
|
64
|
-
for (const [choiceId, picks] of Object.entries(input.picks)) {
|
|
65
|
-
const choice = card.choices.find((c) => c.id === choiceId);
|
|
66
|
-
if (!choice) {
|
|
67
|
-
problems.push(card.choices.length > 0
|
|
68
|
-
? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
|
|
69
|
-
: `${card.id} has no categories to pick.`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const options = Object.keys(choice.options);
|
|
73
|
-
for (const p of picks) {
|
|
74
|
-
if (!options.includes(p)) {
|
|
75
|
-
problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (picks.length === 0 || picks.length > choice.pick) {
|
|
79
|
-
problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
const period = choicePeriod(card, today);
|
|
83
|
-
if (period === null) {
|
|
84
|
-
problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
const kept = held?.chosen?.period === period ? held.chosen.picks : {};
|
|
88
|
-
fields.chosen = {
|
|
89
|
-
period,
|
|
90
|
-
picks: { ...kept, ...input.picks },
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return problems.length > 0 ? problems.join('\n') : fields;
|
|
95
|
-
}
|
|
96
|
-
const applyFields = (held, f) => WalletCardSchema.parse({
|
|
97
|
-
...held,
|
|
98
|
-
...(f.checkout_label === undefined
|
|
99
|
-
? {}
|
|
100
|
-
: { checkout_label: f.checkout_label }),
|
|
101
|
-
...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
|
|
102
|
-
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
103
|
-
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
104
|
-
});
|
|
105
4
|
async function attempt(task) {
|
|
106
5
|
try {
|
|
107
6
|
return await task();
|
|
@@ -111,29 +10,19 @@ async function attempt(task) {
|
|
|
111
10
|
}
|
|
112
11
|
}
|
|
113
12
|
const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
|
|
13
|
+
// The change is worked out on the parsed wallet, then spliced into the file
|
|
14
|
+
// text, which must re-parse to exactly that wallet before it's written.
|
|
15
|
+
async function commit(file, edited, change, created) {
|
|
16
|
+
await commitWalletText(file, edited, change.wallet);
|
|
17
|
+
return { ok: true, wallet: change.wallet, created, notes: change.notes };
|
|
18
|
+
}
|
|
114
19
|
export function addCard(file, catalog, input, today) {
|
|
115
20
|
return attempt(async () => {
|
|
116
|
-
const card = findCatalogCard(catalog, input.id, today);
|
|
117
|
-
if (typeof card === 'string')
|
|
118
|
-
return failed(card);
|
|
119
21
|
const { text, wallet } = await readWalletText(file);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (typeof fields === 'string')
|
|
125
|
-
return failed(fields);
|
|
126
|
-
const held = applyFields(WalletCardSchema.parse({ id: card.id }), fields);
|
|
127
|
-
const expected = wallet
|
|
128
|
-
? { ...wallet, cards: [...wallet.cards, held] }
|
|
129
|
-
: WalletSchema.parse({ cards: [held] });
|
|
130
|
-
await commitWalletText(file, addCardText(text, { id: card.id, ...fields }), expected);
|
|
131
|
-
return {
|
|
132
|
-
ok: true,
|
|
133
|
-
wallet: expected,
|
|
134
|
-
created: text === null,
|
|
135
|
-
notes: setupNotes(catalog, expected, held, today),
|
|
136
|
-
};
|
|
22
|
+
const change = addToWallet(catalog, wallet, input, today);
|
|
23
|
+
if (!change.ok)
|
|
24
|
+
return change;
|
|
25
|
+
return commit(file, addCardText(text, { id: input.id, ...change.fields }), change, text === null);
|
|
137
26
|
});
|
|
138
27
|
}
|
|
139
28
|
export function updateCard(file, catalog, input, today) {
|
|
@@ -141,28 +30,10 @@ export function updateCard(file, catalog, input, today) {
|
|
|
141
30
|
const { text, wallet } = await readWalletText(file);
|
|
142
31
|
if (text === null || wallet === null)
|
|
143
32
|
return failed(noWallet(file));
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
const fields = fieldsFor(card, held, input, today);
|
|
150
|
-
if (typeof fields === 'string')
|
|
151
|
-
return failed(fields);
|
|
152
|
-
if (Object.keys(fields).length === 0)
|
|
153
|
-
return failed('Nothing to change.');
|
|
154
|
-
const updated = applyFields(held, fields);
|
|
155
|
-
const expected = {
|
|
156
|
-
...wallet,
|
|
157
|
-
cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
|
|
158
|
-
};
|
|
159
|
-
await commitWalletText(file, updateCardText(text, held.id, fields), expected);
|
|
160
|
-
return {
|
|
161
|
-
ok: true,
|
|
162
|
-
wallet: expected,
|
|
163
|
-
created: false,
|
|
164
|
-
notes: setupNotes(catalog, expected, updated, today),
|
|
165
|
-
};
|
|
33
|
+
const change = updateInWallet(catalog, wallet, input, today);
|
|
34
|
+
if (!change.ok)
|
|
35
|
+
return change;
|
|
36
|
+
return commit(file, updateCardText(text, input.id, change.fields), change, false);
|
|
166
37
|
});
|
|
167
38
|
}
|
|
168
39
|
export function removeCard(file, _catalog, id) {
|
|
@@ -170,33 +41,20 @@ export function removeCard(file, _catalog, id) {
|
|
|
170
41
|
const { text, wallet } = await readWalletText(file);
|
|
171
42
|
if (text === null || wallet === null)
|
|
172
43
|
return failed(noWallet(file));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
|
|
178
|
-
}
|
|
179
|
-
const expected = {
|
|
180
|
-
...wallet,
|
|
181
|
-
cards: wallet.cards.filter((c) => c.id !== id),
|
|
182
|
-
};
|
|
183
|
-
await commitWalletText(file, removeCardText(text, id), expected);
|
|
184
|
-
return { ok: true, wallet: expected, created: false, notes: [] };
|
|
44
|
+
const change = removeFromWallet(wallet, id);
|
|
45
|
+
if (!change.ok)
|
|
46
|
+
return change;
|
|
47
|
+
return commit(file, removeCardText(text, id), change, false);
|
|
185
48
|
});
|
|
186
49
|
}
|
|
187
50
|
export function setMemberships(file, catalog, memberships) {
|
|
188
51
|
return attempt(async () => {
|
|
189
|
-
const known = knownMemberships(catalog);
|
|
190
|
-
const unknown = memberships.filter((m) => !known.includes(m));
|
|
191
|
-
if (unknown.length > 0) {
|
|
192
|
-
return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
193
|
-
}
|
|
194
52
|
const { text, wallet } = await readWalletText(file);
|
|
195
53
|
if (text === null || wallet === null)
|
|
196
54
|
return failed(noWallet(file));
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return
|
|
55
|
+
const change = withMemberships(catalog, wallet, memberships);
|
|
56
|
+
if (!change.ok)
|
|
57
|
+
return change;
|
|
58
|
+
return commit(file, setMembershipsText(text, change.wallet.memberships), change, false);
|
|
201
59
|
});
|
|
202
60
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
|
+
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
+
import { periodKey } from "./periods.js";
|
|
4
|
+
import { categoryPhrase, currencyLabel, dollars, earnText, num, recommend, } from "./recommend.js";
|
|
5
|
+
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { membershipsUsed } from "./setup.js";
|
|
7
|
+
import { cardValuation } from "./valuation.js";
|
|
8
|
+
const EPSILON = 1e-9;
|
|
9
|
+
export const ALL_PERSONAL_CARDS = {
|
|
10
|
+
kind: 'any',
|
|
11
|
+
business: false,
|
|
12
|
+
noAnnualFee: false,
|
|
13
|
+
issuer: null,
|
|
14
|
+
includeClosed: false,
|
|
15
|
+
};
|
|
16
|
+
export function matchesFilters(card, filters) {
|
|
17
|
+
if (card.business !== filters.business)
|
|
18
|
+
return false;
|
|
19
|
+
if (!filters.includeClosed && card.applications_closed !== undefined)
|
|
20
|
+
return false;
|
|
21
|
+
if (filters.noAnnualFee && card.annual_fee !== 0)
|
|
22
|
+
return false;
|
|
23
|
+
if (filters.issuer !== null && card.issuer !== filters.issuer)
|
|
24
|
+
return false;
|
|
25
|
+
const isCash = card.currency === 'cash';
|
|
26
|
+
if (filters.kind === 'cash' && !isCash)
|
|
27
|
+
return false;
|
|
28
|
+
if (filters.kind === 'points' && isCash)
|
|
29
|
+
return false;
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
// The period a card's picks are keyed by in a wallet.
|
|
33
|
+
export function choicePeriod(card) {
|
|
34
|
+
for (const c of card.choices)
|
|
35
|
+
if (c.period !== 'until_changed')
|
|
36
|
+
return c.period;
|
|
37
|
+
return 'quarter';
|
|
38
|
+
}
|
|
39
|
+
// A card as its holder would set it up for the best result: the given picks,
|
|
40
|
+
// every activation done, and no cap used yet. The anniversary is `date`, so a
|
|
41
|
+
// cardmember-year cap starts fresh.
|
|
42
|
+
export function idealHolding(card, date, picks = {}) {
|
|
43
|
+
const period = choicePeriod(card);
|
|
44
|
+
return {
|
|
45
|
+
id: card.id,
|
|
46
|
+
anniversary: date.slice(5),
|
|
47
|
+
cap_usage: {},
|
|
48
|
+
activated: card.rules
|
|
49
|
+
.filter((r) => r.requires?.activation === true)
|
|
50
|
+
.map((r) => r.id),
|
|
51
|
+
...(Object.keys(picks).length > 0
|
|
52
|
+
? { chosen: { period: periodKey(period, date, null) ?? '', picks } }
|
|
53
|
+
: {}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const PICK_PERIODS = {
|
|
57
|
+
month: 'set each month',
|
|
58
|
+
quarter: 'picked each quarter',
|
|
59
|
+
calendar_year: 'picked each year',
|
|
60
|
+
until_changed: 'until you change it',
|
|
61
|
+
};
|
|
62
|
+
const CAP_PERIODS = {
|
|
63
|
+
month: 'a month',
|
|
64
|
+
quarter: 'a quarter',
|
|
65
|
+
calendar_year: 'a calendar year',
|
|
66
|
+
cardmember_year: 'a cardmember year',
|
|
67
|
+
};
|
|
68
|
+
const plainWords = (s) => s
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
71
|
+
.trim();
|
|
72
|
+
// A category from everyday words: an alias ("groceries") first, then the
|
|
73
|
+
// name, then the id, so "grocery" means supermarkets rather than the parent.
|
|
74
|
+
export function findCategory(catalog, text) {
|
|
75
|
+
const wanted = plainWords(text);
|
|
76
|
+
if (wanted === '')
|
|
77
|
+
return null;
|
|
78
|
+
const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
|
|
79
|
+
return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
|
|
80
|
+
}
|
|
81
|
+
export function categoryResolution(catalog, category, channel) {
|
|
82
|
+
const found = catalog.categories.find((c) => c.id === category);
|
|
83
|
+
if (!found)
|
|
84
|
+
throw new Error(`unknown category '${category}'`);
|
|
85
|
+
return {
|
|
86
|
+
input: found.name,
|
|
87
|
+
merchantId: null,
|
|
88
|
+
resolvedName: found.name,
|
|
89
|
+
category,
|
|
90
|
+
likelyMcc: null,
|
|
91
|
+
accepts: null,
|
|
92
|
+
ambiguity: null,
|
|
93
|
+
channel,
|
|
94
|
+
confidence: 'high',
|
|
95
|
+
source: 'caller',
|
|
96
|
+
bookedVia: [],
|
|
97
|
+
inference: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function walletOf(held, options) {
|
|
101
|
+
return {
|
|
102
|
+
valuations: options.valuations,
|
|
103
|
+
memberships: options.memberships,
|
|
104
|
+
cards: [held],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// One option per choice is enough to cover a single purchase, so each choice
|
|
108
|
+
// is tried option by option, keeping a pick only when it raises the rate.
|
|
109
|
+
function bestPicksForPurchase(catalog, card, merchant, purchase, options) {
|
|
110
|
+
const picks = {};
|
|
111
|
+
const rateWith = (p) => {
|
|
112
|
+
const held = idealHolding(card, purchase.date, p);
|
|
113
|
+
const ctx = purchaseContext(catalog, walletOf(held, options), merchant, merchant.category, purchase);
|
|
114
|
+
return evaluateCard(card, held, ctx).best.rate;
|
|
115
|
+
};
|
|
116
|
+
for (const choice of card.choices) {
|
|
117
|
+
let best = rateWith(picks);
|
|
118
|
+
for (const option of Object.keys(choice.options)) {
|
|
119
|
+
const trial = { ...picks, [choice.id]: [option] };
|
|
120
|
+
const rate = rateWith(trial);
|
|
121
|
+
if (rate > best + EPSILON) {
|
|
122
|
+
best = rate;
|
|
123
|
+
picks[choice.id] = [option];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return picks;
|
|
128
|
+
}
|
|
129
|
+
function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
130
|
+
const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
|
|
131
|
+
const { best } = evaluateCard(card, held, ctx);
|
|
132
|
+
const rule = best.rule;
|
|
133
|
+
const none = { pick: null, activation: null, membership: null, cap: null };
|
|
134
|
+
if (!rule)
|
|
135
|
+
return none;
|
|
136
|
+
const choiceId = rule.match.choice;
|
|
137
|
+
const choice = card.choices.find((c) => c.id === choiceId);
|
|
138
|
+
const picked = choiceId === undefined ? undefined : held.chosen?.picks[choiceId];
|
|
139
|
+
const cap = best.cap?.cap;
|
|
140
|
+
return {
|
|
141
|
+
pick: choice && picked
|
|
142
|
+
? { choice: choice.id, options: picked, period: choice.period }
|
|
143
|
+
: null,
|
|
144
|
+
activation: rule.requires?.activation === true ? rule.id : null,
|
|
145
|
+
membership: rule.requires?.membership ?? null,
|
|
146
|
+
cap: cap
|
|
147
|
+
? {
|
|
148
|
+
amount: cap.amount,
|
|
149
|
+
period: cap.period,
|
|
150
|
+
thenRate: cap.then_rate,
|
|
151
|
+
currency: cardValuation(catalog, wallet, card).currency,
|
|
152
|
+
}
|
|
153
|
+
: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function conditionsText(catalog, r) {
|
|
157
|
+
const conditions = [];
|
|
158
|
+
if (r.pick) {
|
|
159
|
+
conditions.push(`Needs "${r.pick.options.join('", "')}" as its ${r.pick.choice} pick (${PICK_PERIODS[r.pick.period]}).`);
|
|
160
|
+
}
|
|
161
|
+
if (r.activation !== null)
|
|
162
|
+
conditions.push(`Needs activating with the issuer ("${r.activation}").`);
|
|
163
|
+
if (r.membership !== null)
|
|
164
|
+
conditions.push(`Needs ${r.membership}.`);
|
|
165
|
+
if (r.cap) {
|
|
166
|
+
conditions.push(`Up to ${dollars(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
|
|
167
|
+
}
|
|
168
|
+
return conditions;
|
|
169
|
+
}
|
|
170
|
+
function membershipUpgrades(catalog, card, held, merchant, purchase, options, current) {
|
|
171
|
+
const upgrades = [];
|
|
172
|
+
for (const membership of membershipsUsed(card)) {
|
|
173
|
+
if (options.memberships.includes(membership))
|
|
174
|
+
continue;
|
|
175
|
+
const wallet = walletOf(held, {
|
|
176
|
+
...options,
|
|
177
|
+
memberships: [...options.memberships, membership],
|
|
178
|
+
});
|
|
179
|
+
const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
|
|
180
|
+
const pct = evaluateCard(card, held, ctx).best.rate *
|
|
181
|
+
cardValuation(catalog, wallet, card).centsPerUnit;
|
|
182
|
+
if (pct > current + EPSILON)
|
|
183
|
+
upgrades.push({ membership, effectiveReturnPct: pct });
|
|
184
|
+
}
|
|
185
|
+
return upgrades.sort((a, b) => b.effectiveReturnPct - a.effectiveReturnPct);
|
|
186
|
+
}
|
|
187
|
+
function compareExplored(a, b) {
|
|
188
|
+
const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
|
|
189
|
+
if (Math.abs(byReturn) > EPSILON)
|
|
190
|
+
return byReturn;
|
|
191
|
+
const byFee = (a.annualFee ?? Infinity) - (b.annualFee ?? Infinity);
|
|
192
|
+
if (byFee !== 0)
|
|
193
|
+
return byFee;
|
|
194
|
+
const byConditions = a.conditions.length - b.conditions.length;
|
|
195
|
+
if (byConditions !== 0)
|
|
196
|
+
return byConditions;
|
|
197
|
+
return a.cardId.localeCompare(b.cardId);
|
|
198
|
+
}
|
|
199
|
+
// Ranks every card in the catalog that passes the filters for one purchase,
|
|
200
|
+
// each set up at its best: the pick that covers it, activated, cap unused.
|
|
201
|
+
export function exploreCards(catalog, query, options) {
|
|
202
|
+
const merchant = query.merchant !== null
|
|
203
|
+
? resolveMerchant(catalog, query.merchant, query.channel)
|
|
204
|
+
: categoryResolution(catalog, query.category ?? 'other', query.channel);
|
|
205
|
+
const purchase = {
|
|
206
|
+
merchant: merchant.input,
|
|
207
|
+
amount: query.amount,
|
|
208
|
+
channel: query.channel,
|
|
209
|
+
date: query.date,
|
|
210
|
+
country: 'US',
|
|
211
|
+
};
|
|
212
|
+
const ranked = [];
|
|
213
|
+
const excluded = [];
|
|
214
|
+
for (const card of catalog.cards) {
|
|
215
|
+
if (!matchesFilters(card, options.filters))
|
|
216
|
+
continue;
|
|
217
|
+
const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
|
|
218
|
+
const held = idealHolding(card, query.date, picks);
|
|
219
|
+
const wallet = walletOf(held, options);
|
|
220
|
+
const rec = recommend(catalog, wallet, purchase, merchant);
|
|
221
|
+
const top = rec.ranked[0];
|
|
222
|
+
if (!top) {
|
|
223
|
+
excluded.push(...rec.excluded);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
227
|
+
const gap = rotationGap(card, query.date);
|
|
228
|
+
const evidenceRule = top.matchedRule ?? 'base_rate';
|
|
229
|
+
const evidence = card.evidence.find((e) => e.rule === evidenceRule);
|
|
230
|
+
ranked.push({
|
|
231
|
+
...top,
|
|
232
|
+
warnings: gap === null
|
|
233
|
+
? top.warnings
|
|
234
|
+
: [
|
|
235
|
+
...top.warnings,
|
|
236
|
+
`Its rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them.`,
|
|
237
|
+
],
|
|
238
|
+
issuer: card.issuer,
|
|
239
|
+
network: card.network,
|
|
240
|
+
annualFee: card.annual_fee ?? null,
|
|
241
|
+
firstYearFee: card.first_year_fee ?? null,
|
|
242
|
+
picks,
|
|
243
|
+
requirements,
|
|
244
|
+
conditions: conditionsText(catalog, requirements),
|
|
245
|
+
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
246
|
+
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
ranked.sort(compareExplored);
|
|
250
|
+
return {
|
|
251
|
+
merchant,
|
|
252
|
+
ranked,
|
|
253
|
+
excluded,
|
|
254
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options),
|
|
255
|
+
warnings: exploreWarnings(catalog, merchant),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
259
|
+
const assumptions = [];
|
|
260
|
+
if (merchant.source === 'caller') {
|
|
261
|
+
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
262
|
+
}
|
|
263
|
+
else if (merchant.source !== 'none') {
|
|
264
|
+
const code = merchant.likelyMcc === null ? '' : ` (MCC ${String(merchant.likelyMcc)})`;
|
|
265
|
+
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
266
|
+
}
|
|
267
|
+
assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
|
|
268
|
+
assumptions.push(options.memberships.length === 0
|
|
269
|
+
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
270
|
+
: `Assumes these memberships: ${options.memberships.join(', ')}.`);
|
|
271
|
+
const values = new Map();
|
|
272
|
+
for (const e of ranked.slice(0, 10)) {
|
|
273
|
+
if (e.currency !== 'cash')
|
|
274
|
+
values.set(e.currency, e.centsPerUnit);
|
|
275
|
+
}
|
|
276
|
+
if (values.size > 0) {
|
|
277
|
+
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
278
|
+
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
279
|
+
}
|
|
280
|
+
return assumptions;
|
|
281
|
+
}
|
|
282
|
+
function exploreWarnings(catalog, merchant) {
|
|
283
|
+
const warnings = [];
|
|
284
|
+
if (merchant.source === 'none') {
|
|
285
|
+
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. Try a category instead.`);
|
|
286
|
+
}
|
|
287
|
+
if (merchant.ambiguity) {
|
|
288
|
+
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
289
|
+
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
290
|
+
}
|
|
291
|
+
return warnings;
|
|
292
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -20,14 +20,14 @@ const PERIOD_WORDS = {
|
|
|
20
20
|
cardmember_year: 'cardmember-year',
|
|
21
21
|
};
|
|
22
22
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
23
|
-
const num = (n) => String(Number(n.toFixed(2)));
|
|
23
|
+
export const num = (n) => String(Number(n.toFixed(2)));
|
|
24
24
|
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
25
25
|
const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
26
|
-
const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
27
|
-
function currencyName(catalog, id) {
|
|
26
|
+
export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
27
|
+
export function currencyName(catalog, id) {
|
|
28
28
|
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
29
29
|
}
|
|
30
|
-
function currencyLabel(catalog, id) {
|
|
30
|
+
export function currencyLabel(catalog, id) {
|
|
31
31
|
const name = currencyName(catalog, id);
|
|
32
32
|
return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
|
|
33
33
|
}
|
|
@@ -39,13 +39,13 @@ function categoryName(catalog, id) {
|
|
|
39
39
|
}
|
|
40
40
|
// Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
|
|
41
41
|
// internet service" and "EV charging" keep their acronyms.
|
|
42
|
-
function categoryPhrase(catalog, id) {
|
|
42
|
+
export function categoryPhrase(catalog, id) {
|
|
43
43
|
const name = categoryName(catalog, id);
|
|
44
44
|
return /^[A-Z]{2}/.test(name)
|
|
45
45
|
? name
|
|
46
46
|
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
47
47
|
}
|
|
48
|
-
function earnText(catalog, rate, currency) {
|
|
48
|
+
export function earnText(catalog, rate, currency) {
|
|
49
49
|
return currency === 'cash'
|
|
50
50
|
? `${num(rate)}% cash back`
|
|
51
51
|
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
@@ -333,7 +333,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
333
333
|
const gap = card ? rotationGap(card, purchase.date) : null;
|
|
334
334
|
if (card && gap !== null) {
|
|
335
335
|
const name = held.checkout_label ?? card.name;
|
|
336
|
-
warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more.`);
|
|
336
|
+
warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
const tie = tieNote(entries);
|
package/dist/engine/setup.js
CHANGED
|
@@ -161,7 +161,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
161
161
|
if (gap !== null) {
|
|
162
162
|
notes.push({
|
|
163
163
|
kind: 'rotation',
|
|
164
|
-
text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet.`,
|
|
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
165
|
});
|
|
166
166
|
}
|
|
167
167
|
return notes;
|