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,162 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { isMap, isSeq, parseDocument, stringify } from 'yaml';
|
|
5
|
+
import { WalletSchema } from "../schema/wallet.js";
|
|
6
|
+
const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
|
|
7
|
+
# Format: https://github.com/cwasley/earnrate#your-wallet
|
|
8
|
+
|
|
9
|
+
`;
|
|
10
|
+
const BY_HAND = 'edit the wallet by hand';
|
|
11
|
+
const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
|
|
12
|
+
function afterLine(text, at) {
|
|
13
|
+
const end = text.indexOf('\n', at);
|
|
14
|
+
return end === -1 ? text.length : end + 1;
|
|
15
|
+
}
|
|
16
|
+
function insertLines(text, at, lines) {
|
|
17
|
+
const head = text.slice(0, at);
|
|
18
|
+
const gap = head.length > 0 && !head.endsWith('\n') ? '\n' : '';
|
|
19
|
+
return `${head}${gap}${lines}${text.slice(at)}`;
|
|
20
|
+
}
|
|
21
|
+
function range(node) {
|
|
22
|
+
const r = node?.range;
|
|
23
|
+
if (!r)
|
|
24
|
+
throw new Error(`a wallet value has no source position; ${BY_HAND}`);
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
27
|
+
const keyOf = (pair) => pair.key?.value;
|
|
28
|
+
const scalar = (value) => stringify(value).trimEnd();
|
|
29
|
+
const flowList = (ids) => `[${ids.join(', ')}]`;
|
|
30
|
+
function fieldLines(field, f, indent) {
|
|
31
|
+
switch (field) {
|
|
32
|
+
case 'checkout_label':
|
|
33
|
+
case 'anniversary': {
|
|
34
|
+
const value = f[field];
|
|
35
|
+
return value === undefined ? '' : `${indent}${field}: ${scalar(value)}\n`;
|
|
36
|
+
}
|
|
37
|
+
case 'activated':
|
|
38
|
+
return f.activated === undefined
|
|
39
|
+
? ''
|
|
40
|
+
: `${indent}activated: ${flowList(f.activated)}\n`;
|
|
41
|
+
case 'chosen': {
|
|
42
|
+
if (f.chosen === undefined)
|
|
43
|
+
return '';
|
|
44
|
+
const picks = Object.entries(f.chosen.picks)
|
|
45
|
+
.map(([choice, ids]) => `${indent} ${choice}: ${flowList(ids)}\n`)
|
|
46
|
+
.join('');
|
|
47
|
+
return `${indent}chosen:\n${indent} period: ${scalar(f.chosen.period)}\n${indent} picks:\n${picks}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const FIELDS = ['checkout_label', 'anniversary', 'chosen', 'activated'];
|
|
52
|
+
function cardsSeq(text) {
|
|
53
|
+
const doc = parseDocument(text);
|
|
54
|
+
if (doc.errors.length > 0)
|
|
55
|
+
throw new Error(doc.errors[0]?.message);
|
|
56
|
+
const cards = doc.get('cards', true);
|
|
57
|
+
if (!isSeq(cards) || cards.flow) {
|
|
58
|
+
throw new Error(`"cards" isn't a block list; ${BY_HAND}`);
|
|
59
|
+
}
|
|
60
|
+
return { doc, cards };
|
|
61
|
+
}
|
|
62
|
+
function findCard(text, cardId) {
|
|
63
|
+
const { cards } = cardsSeq(text);
|
|
64
|
+
const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
|
|
65
|
+
if (!isMap(card))
|
|
66
|
+
throw new Error(`no card '${cardId}' in the wallet`);
|
|
67
|
+
if (card.flow)
|
|
68
|
+
throw new Error(`card '${cardId}' is written inline; ${BY_HAND}`);
|
|
69
|
+
return card;
|
|
70
|
+
}
|
|
71
|
+
const column = (text, at) => at - lineStart(text, at);
|
|
72
|
+
// Appends a card after the last one, matching the list's indentation.
|
|
73
|
+
export function addCardText(text, entry) {
|
|
74
|
+
if (text === null || text.trim() === '') {
|
|
75
|
+
return `${NEW_WALLET}cards:\n${cardLines(entry, ' ')}`;
|
|
76
|
+
}
|
|
77
|
+
const { cards } = cardsSeq(text);
|
|
78
|
+
const first = cards.items[0];
|
|
79
|
+
if (!isMap(first))
|
|
80
|
+
throw new Error(`"cards" is empty; ${BY_HAND}`);
|
|
81
|
+
const keyColumn = column(text, range(first)[0]);
|
|
82
|
+
if (keyColumn < 2 || text[range(first)[0] - 2] !== '-') {
|
|
83
|
+
throw new Error(`can't tell how the cards are indented; ${BY_HAND}`);
|
|
84
|
+
}
|
|
85
|
+
const at = afterLine(text, range(cards)[1] - 1);
|
|
86
|
+
return insertLines(text, at, cardLines(entry, ' '.repeat(keyColumn - 2)));
|
|
87
|
+
}
|
|
88
|
+
function cardLines(entry, dashIndent) {
|
|
89
|
+
const indent = `${dashIndent} `;
|
|
90
|
+
return `${dashIndent}- id: ${entry.id}\n${FIELDS.map((f) => fieldLines(f, entry, indent)).join('')}`;
|
|
91
|
+
}
|
|
92
|
+
export function removeCardText(text, cardId) {
|
|
93
|
+
const card = findCard(text, cardId);
|
|
94
|
+
const start = lineStart(text, range(card)[0]);
|
|
95
|
+
const end = afterLine(text, range(card)[1] - 1);
|
|
96
|
+
return text.slice(0, start) + text.slice(end);
|
|
97
|
+
}
|
|
98
|
+
// Replaces each given field in place, or adds it after the card's last line.
|
|
99
|
+
export function updateCardText(text, cardId, fields) {
|
|
100
|
+
let out = text;
|
|
101
|
+
for (const field of FIELDS) {
|
|
102
|
+
if (fields[field] === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const card = findCard(out, cardId);
|
|
105
|
+
const indent = ' '.repeat(column(out, range(card)[0]));
|
|
106
|
+
const lines = fieldLines(field, fields, indent);
|
|
107
|
+
const pair = card.items.find((p) => keyOf(p) === field);
|
|
108
|
+
if (pair) {
|
|
109
|
+
const start = range(pair.key)[0];
|
|
110
|
+
const end = afterLine(out, range(pair.value)[1] - 1);
|
|
111
|
+
out = out.slice(0, start) + lines.trimStart() + out.slice(end);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
out = insertLines(out, afterLine(out, range(card)[1] - 1), lines);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
export function setMembershipsText(text, memberships) {
|
|
120
|
+
const { doc } = cardsSeq(text);
|
|
121
|
+
const root = doc.contents;
|
|
122
|
+
if (!isMap(root))
|
|
123
|
+
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
124
|
+
const line = `memberships: ${flowList(memberships)}\n`;
|
|
125
|
+
const pair = root.items.find((p) => keyOf(p) === 'memberships');
|
|
126
|
+
if (pair) {
|
|
127
|
+
const start = range(pair.key)[0];
|
|
128
|
+
const end = afterLine(text, (pair.value === null ? range(pair.key) : range(pair.value))[1] - 1);
|
|
129
|
+
return text.slice(0, start) + line + text.slice(end);
|
|
130
|
+
}
|
|
131
|
+
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
132
|
+
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
133
|
+
}
|
|
134
|
+
async function readText(file) {
|
|
135
|
+
try {
|
|
136
|
+
return await readFile(file, 'utf8');
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
|
|
140
|
+
return null;
|
|
141
|
+
throw e;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function readWalletText(file) {
|
|
145
|
+
const text = await readText(file);
|
|
146
|
+
return {
|
|
147
|
+
text,
|
|
148
|
+
wallet: text === null ? null : WalletSchema.parse(parseDocument(text).toJS()),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Writes `after` only if it parses to exactly `expected`, so a splice that
|
|
152
|
+
// went wrong can never change anything it wasn't meant to.
|
|
153
|
+
export async function commitWalletText(file, after, expected) {
|
|
154
|
+
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
155
|
+
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
156
|
+
throw new Error(`couldn't update ${file} safely; ${BY_HAND}`);
|
|
157
|
+
}
|
|
158
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
159
|
+
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
160
|
+
await writeFile(tmp, after);
|
|
161
|
+
await rename(tmp, file);
|
|
162
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
|
+
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
|
|
3
|
+
const failed = (error) => ({ ok: false, error });
|
|
4
|
+
async function attempt(task) {
|
|
5
|
+
try {
|
|
6
|
+
return await task();
|
|
7
|
+
}
|
|
8
|
+
catch (e) {
|
|
9
|
+
return failed(e instanceof Error ? e.message : String(e));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
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
|
+
}
|
|
19
|
+
export function addCard(file, catalog, input, today) {
|
|
20
|
+
return attempt(async () => {
|
|
21
|
+
const { text, wallet } = await readWalletText(file);
|
|
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);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function updateCard(file, catalog, input, today) {
|
|
29
|
+
return attempt(async () => {
|
|
30
|
+
const { text, wallet } = await readWalletText(file);
|
|
31
|
+
if (text === null || wallet === null)
|
|
32
|
+
return failed(noWallet(file));
|
|
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);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function removeCard(file, _catalog, id) {
|
|
40
|
+
return attempt(async () => {
|
|
41
|
+
const { text, wallet } = await readWalletText(file);
|
|
42
|
+
if (text === null || wallet === null)
|
|
43
|
+
return failed(noWallet(file));
|
|
44
|
+
const change = removeFromWallet(wallet, id);
|
|
45
|
+
if (!change.ok)
|
|
46
|
+
return change;
|
|
47
|
+
return commit(file, removeCardText(text, id), change, false);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function setMemberships(file, catalog, memberships) {
|
|
51
|
+
return attempt(async () => {
|
|
52
|
+
const { text, wallet } = await readWalletText(file);
|
|
53
|
+
if (text === null || wallet === null)
|
|
54
|
+
return failed(noWallet(file));
|
|
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);
|
|
59
|
+
});
|
|
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
|
@@ -2,6 +2,7 @@ import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
|
2
2
|
import { evaluateCard, purchaseContext, } from "./evaluate.js";
|
|
3
3
|
import { bonusStatus } from "./bonus.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
|
+
import { rotationGap } from "./rotation.js";
|
|
5
6
|
import { cardValuation } from "./valuation.js";
|
|
6
7
|
const EPSILON = 1e-9;
|
|
7
8
|
export const STALE_AFTER_DAYS = 180;
|
|
@@ -19,14 +20,14 @@ const PERIOD_WORDS = {
|
|
|
19
20
|
cardmember_year: 'cardmember-year',
|
|
20
21
|
};
|
|
21
22
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
22
|
-
const num = (n) => String(Number(n.toFixed(2)));
|
|
23
|
+
export const num = (n) => String(Number(n.toFixed(2)));
|
|
23
24
|
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
24
25
|
const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
25
|
-
const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
26
|
-
function currencyName(catalog, id) {
|
|
26
|
+
export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
27
|
+
export function currencyName(catalog, id) {
|
|
27
28
|
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
28
29
|
}
|
|
29
|
-
function currencyLabel(catalog, id) {
|
|
30
|
+
export function currencyLabel(catalog, id) {
|
|
30
31
|
const name = currencyName(catalog, id);
|
|
31
32
|
return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
|
|
32
33
|
}
|
|
@@ -38,13 +39,13 @@ function categoryName(catalog, id) {
|
|
|
38
39
|
}
|
|
39
40
|
// Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
|
|
40
41
|
// internet service" and "EV charging" keep their acronyms.
|
|
41
|
-
function categoryPhrase(catalog, id) {
|
|
42
|
+
export function categoryPhrase(catalog, id) {
|
|
42
43
|
const name = categoryName(catalog, id);
|
|
43
44
|
return /^[A-Z]{2}/.test(name)
|
|
44
45
|
? name
|
|
45
46
|
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
46
47
|
}
|
|
47
|
-
function earnText(catalog, rate, currency) {
|
|
48
|
+
export function earnText(catalog, rate, currency) {
|
|
48
49
|
return currency === 'cash'
|
|
49
50
|
? `${num(rate)}% cash back`
|
|
50
51
|
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
@@ -327,6 +328,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
327
328
|
: '';
|
|
328
329
|
warnings.push(`Not every business takes ${NETWORK_NAMES[winner.card.network] ?? winner.card.network}.${fallback}`);
|
|
329
330
|
}
|
|
331
|
+
for (const held of wallet.cards) {
|
|
332
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
333
|
+
const gap = card ? rotationGap(card, purchase.date) : null;
|
|
334
|
+
if (card && gap !== null) {
|
|
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. An earnrate update adds them once the issuer announces them.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
330
339
|
const tie = tieNote(entries);
|
|
331
340
|
if (tie)
|
|
332
341
|
notes.push(tie);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { periodKey } from "./periods.js";
|
|
2
|
+
// The period of the card's rotating categories that `date` falls in, when no
|
|
3
|
+
// rule in the data covers it yet; null when it's covered or the card doesn't
|
|
4
|
+
// rotate.
|
|
5
|
+
export function rotationGap(card, date) {
|
|
6
|
+
for (const cap of card.caps) {
|
|
7
|
+
if (!cap.rotating || cap.period === 'cardmember_year')
|
|
8
|
+
continue;
|
|
9
|
+
const covered = card.rules.some((r) => r.cap === cap.id &&
|
|
10
|
+
(r.valid_from ?? '') <= date &&
|
|
11
|
+
date <= (r.valid_to ?? ''));
|
|
12
|
+
if (!covered)
|
|
13
|
+
return periodKey(cap.period, date, null);
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|