earnrate 0.2.0 → 0.2.2
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 +23 -20
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -3
- package/data/cards/amex-blue-cash-preferred.yaml +5 -7
- package/data/cards/amex-gold.yaml +1 -0
- package/data/cards/amex-platinum.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
- package/data/cards/chase-freedom-flex.yaml +5 -1
- package/data/cards/chase-southwest-plus.yaml +5 -5
- package/data/cards/chase-southwest-premier-business.yaml +8 -5
- package/data/cards/chase-southwest-premier.yaml +6 -6
- package/data/cards/chase-southwest-priority.yaml +6 -6
- package/data/cards/citi-aadvantage-business.yaml +6 -6
- package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +64 -51
- package/dist/cli/cheatsheet.js +9 -12
- package/dist/cli/explore.js +28 -27
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +7 -6
- package/dist/cli/run.js +17 -13
- package/dist/cli/wallet.js +7 -6
- package/dist/data/file-store.js +54 -15
- package/dist/data/load.js +1 -1
- package/dist/data/store.js +0 -16
- package/dist/data/wallet-edit.js +78 -3
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +56 -13
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +10 -22
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- package/dist/engine/setup.js +2 -2
- package/dist/engine/simulate.js +12 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +5 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +46 -19
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +1 -1
- package/dist/format/explore.js +3 -13
- package/dist/format/offers.js +15 -18
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +57 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +155 -0
- package/dist/mcp/schemas.js +2 -1
- package/dist/mcp/server.js +15 -819
- package/dist/mcp/tools/account.js +78 -0
- package/dist/mcp/tools/answers.js +338 -0
- package/dist/mcp/tools/wallet.js +128 -0
- package/dist/merchants/from-env.js +1 -1
- package/dist/merchants/resolve.js +3 -3
- package/dist/schema/card.js +4 -0
- package/package.json +3 -2
- package/dist/data/wallet-setup.js +0 -60
- package/dist/data/wallet-write.js +0 -140
- /package/dist/{cli → format}/cards.js +0 -0
package/dist/data/store.js
CHANGED
|
@@ -76,8 +76,6 @@ export class JsonWalletStore {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
// A store with nothing behind it, for tests and for a session that starts
|
|
80
|
-
// empty and is thrown away.
|
|
81
79
|
export function memoryBackend(initial = null) {
|
|
82
80
|
let text = initial;
|
|
83
81
|
return {
|
|
@@ -92,17 +90,3 @@ export function memoryBackend(initial = null) {
|
|
|
92
90
|
},
|
|
93
91
|
};
|
|
94
92
|
}
|
|
95
|
-
// No saved wallet, and saving refused: the public remote server before
|
|
96
|
-
// anonymous saved wallets exist. Cards named in the conversation still work.
|
|
97
|
-
export function noSavedWallet(where) {
|
|
98
|
-
const refused = () => Promise.resolve(failed("Saving a wallet isn't available here yet. Name your cards in the conversation instead; nothing is stored."));
|
|
99
|
-
return {
|
|
100
|
-
where,
|
|
101
|
-
load: () => Promise.resolve(null),
|
|
102
|
-
addCard: refused,
|
|
103
|
-
updateCard: refused,
|
|
104
|
-
removeCard: refused,
|
|
105
|
-
setMemberships: refused,
|
|
106
|
-
record: refused,
|
|
107
|
-
};
|
|
108
|
-
}
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import { isMap, isSeq, parseDocument, stringify } from 'yaml';
|
|
5
|
+
import { applyBonusBooking, applyBooking, } from "../engine/record.js";
|
|
5
6
|
import { WalletSchema } from "../schema/wallet.js";
|
|
6
7
|
const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
|
|
7
8
|
# Format: https://github.com/cwasley/earnrate#your-wallet
|
|
@@ -32,12 +33,20 @@ function fieldLines(field, f, indent) {
|
|
|
32
33
|
case 'checkout_label':
|
|
33
34
|
case 'anniversary': {
|
|
34
35
|
const value = f[field];
|
|
35
|
-
return value
|
|
36
|
+
return value == null ? '' : `${indent}${field}: ${scalar(value)}\n`;
|
|
36
37
|
}
|
|
37
38
|
case 'activated':
|
|
38
39
|
return f.activated === undefined
|
|
39
40
|
? ''
|
|
40
41
|
: `${indent}activated: ${flowList(f.activated)}\n`;
|
|
42
|
+
case 'cap_usage': {
|
|
43
|
+
if (f.cap_usage === undefined)
|
|
44
|
+
return '';
|
|
45
|
+
const caps = Object.entries(f.cap_usage)
|
|
46
|
+
.map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, spent: ${String(u.spent)} }\n`)
|
|
47
|
+
.join('');
|
|
48
|
+
return caps === '' ? '' : `${indent}cap_usage:\n${caps}`;
|
|
49
|
+
}
|
|
41
50
|
case 'bonus': {
|
|
42
51
|
if (!f.bonus)
|
|
43
52
|
return '';
|
|
@@ -65,6 +74,7 @@ const FIELDS = [
|
|
|
65
74
|
'anniversary',
|
|
66
75
|
'chosen',
|
|
67
76
|
'activated',
|
|
77
|
+
'cap_usage',
|
|
68
78
|
'bonus',
|
|
69
79
|
];
|
|
70
80
|
function cardsSeq(text) {
|
|
@@ -154,6 +164,71 @@ export function setMembershipsText(text, memberships) {
|
|
|
154
164
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
155
165
|
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
156
166
|
}
|
|
167
|
+
function flowUsage(booking) {
|
|
168
|
+
const period = /^\d+$/.test(booking.period)
|
|
169
|
+
? `"${booking.period}"`
|
|
170
|
+
: booking.period;
|
|
171
|
+
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
172
|
+
}
|
|
173
|
+
export function capUsageText(text, booking) {
|
|
174
|
+
const card = findCard(text, booking.cardId);
|
|
175
|
+
const value = flowUsage(booking);
|
|
176
|
+
const usage = card.get('cap_usage', true);
|
|
177
|
+
if (isMap(usage) && !usage.flow && usage.items.length > 0) {
|
|
178
|
+
const existing = usage.items.find((p) => keyOf(p) === booking.capKey);
|
|
179
|
+
if (existing) {
|
|
180
|
+
const [start, end] = range(existing.value);
|
|
181
|
+
return text.slice(0, start) + value + text.slice(end);
|
|
182
|
+
}
|
|
183
|
+
const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
|
|
184
|
+
const at = afterLine(text, range(usage)[1] - 1);
|
|
185
|
+
return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
|
|
186
|
+
}
|
|
187
|
+
if (usage !== undefined)
|
|
188
|
+
throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; ${BY_HAND}`);
|
|
189
|
+
const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
|
|
190
|
+
const at = afterLine(text, range(card)[1] - 1);
|
|
191
|
+
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
192
|
+
}
|
|
193
|
+
export function bonusSpentText(text, cardId, spent) {
|
|
194
|
+
const bonus = findCard(text, cardId).get('bonus', true);
|
|
195
|
+
if (!isMap(bonus))
|
|
196
|
+
throw new Error(`card '${cardId}' has no bonus`);
|
|
197
|
+
const value = String(spent);
|
|
198
|
+
const existing = bonus.items.find((p) => keyOf(p) === 'spent');
|
|
199
|
+
if (existing) {
|
|
200
|
+
const [start, end] = range(existing.value);
|
|
201
|
+
return text.slice(0, start) + value + text.slice(end);
|
|
202
|
+
}
|
|
203
|
+
if (bonus.flow) {
|
|
204
|
+
const close = text.lastIndexOf('}', range(bonus)[1]);
|
|
205
|
+
return `${text.slice(0, close).trimEnd()}, spent: ${value} ${text.slice(close)}`;
|
|
206
|
+
}
|
|
207
|
+
const indent = ' '.repeat(column(text, range(bonus.items[0]?.key)[0]));
|
|
208
|
+
const at = afterLine(text, range(bonus)[1] - 1);
|
|
209
|
+
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
210
|
+
}
|
|
211
|
+
// Writes spending toward caps and welcome bonuses in one checked write.
|
|
212
|
+
export async function writeProgress(file, caps, bonuses) {
|
|
213
|
+
const { text, wallet } = await readWalletText(file);
|
|
214
|
+
if (text === null || wallet === null)
|
|
215
|
+
throw new Error(`No wallet at ${file}.`);
|
|
216
|
+
let after = text;
|
|
217
|
+
let expected = wallet;
|
|
218
|
+
for (const b of caps) {
|
|
219
|
+
after = capUsageText(after, b);
|
|
220
|
+
expected = applyBooking(expected, b);
|
|
221
|
+
}
|
|
222
|
+
for (const b of bonuses) {
|
|
223
|
+
after = bonusSpentText(after, b.cardId, b.after);
|
|
224
|
+
expected = applyBonusBooking(expected, b);
|
|
225
|
+
}
|
|
226
|
+
const byHand = [
|
|
227
|
+
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
|
|
228
|
+
...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
|
|
229
|
+
].join('; ');
|
|
230
|
+
await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
|
|
231
|
+
}
|
|
157
232
|
async function readText(file) {
|
|
158
233
|
try {
|
|
159
234
|
return await readFile(file, 'utf8');
|
|
@@ -173,10 +248,10 @@ export async function readWalletText(file) {
|
|
|
173
248
|
}
|
|
174
249
|
// Writes `after` only if it parses to exactly `expected`, so a splice that
|
|
175
250
|
// went wrong can never change anything it wasn't meant to.
|
|
176
|
-
export async function commitWalletText(file, after, expected) {
|
|
251
|
+
export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
|
|
177
252
|
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
178
253
|
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
179
|
-
throw new Error(`couldn't update ${file} safely; ${
|
|
254
|
+
throw new Error(`couldn't update ${file} safely; ${fallback}`);
|
|
180
255
|
}
|
|
181
256
|
await mkdir(path.dirname(file), { recursive: true });
|
|
182
257
|
const tmp = `${file}.${String(process.pid)}.tmp`;
|
package/dist/engine/bonus.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { daysBetween } from "./periods.js";
|
|
2
|
+
import { roundCents } from "./text.js";
|
|
2
3
|
import { cardValuation } from "./valuation.js";
|
|
3
4
|
// "Within 3 months of opening": the same day of the month, three months on,
|
|
4
5
|
// or the month's last day when that day doesn't exist (Nov 30 + 3 → Feb 28).
|
|
@@ -11,7 +12,17 @@ export function addMonths(date, months) {
|
|
|
11
12
|
const day = Math.min(d, last);
|
|
12
13
|
return `${String(year)}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
// A welcome bonus in dollars at the given point value; null when it has
|
|
16
|
+
// neither points nor dollars.
|
|
17
|
+
export function bonusDollars(card, centsPerUnit, points, dollars) {
|
|
18
|
+
if (points !== undefined)
|
|
19
|
+
return (points * centsPerUnit) / 100;
|
|
20
|
+
if (dollars === undefined)
|
|
21
|
+
return null;
|
|
22
|
+
// A cash back card's bonus is paid like its rewards, so it pools when they
|
|
23
|
+
// do; a points card's dollar bonus is a statement credit.
|
|
24
|
+
return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
|
|
25
|
+
}
|
|
15
26
|
export function bonusStatus(catalog, wallet, held, today) {
|
|
16
27
|
const bonus = held.bonus;
|
|
17
28
|
if (!bonus)
|
|
@@ -25,22 +36,14 @@ export function bonusStatus(catalog, wallet, held, today) {
|
|
|
25
36
|
const valuation = cardValuation(catalog, wallet, card);
|
|
26
37
|
const points = bonus.points ?? (bonus.dollars === undefined ? offer?.points : undefined);
|
|
27
38
|
const dollars = bonus.dollars ?? (bonus.points === undefined ? offer?.dollars : undefined);
|
|
28
|
-
|
|
29
|
-
if (points !== undefined)
|
|
30
|
-
value = (points * valuation.centsPerUnit) / 100;
|
|
31
|
-
else if (dollars !== undefined) {
|
|
32
|
-
// A cash back card's bonus is paid like its rewards, so it pools when they
|
|
33
|
-
// do; a points card's dollar bonus is a statement credit.
|
|
34
|
-
value =
|
|
35
|
-
card.currency === 'cash' ? dollars * valuation.centsPerUnit : dollars;
|
|
36
|
-
}
|
|
39
|
+
const value = bonusDollars(card, valuation.centsPerUnit, points, dollars);
|
|
37
40
|
const base = {
|
|
38
41
|
cardId: held.id,
|
|
39
42
|
opened: bonus.opened,
|
|
40
43
|
spent: bonus.spent,
|
|
41
44
|
spend,
|
|
42
45
|
deadline,
|
|
43
|
-
value: value === null ? null :
|
|
46
|
+
value: value === null ? null : roundCents(value),
|
|
44
47
|
};
|
|
45
48
|
if (spend === null || deadline === null) {
|
|
46
49
|
return {
|
|
@@ -50,7 +53,7 @@ export function bonusStatus(catalog, wallet, held, today) {
|
|
|
50
53
|
state: 'incomplete',
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
|
-
const remaining =
|
|
56
|
+
const remaining = roundCents(Math.max(0, spend - bonus.spent));
|
|
54
57
|
const daysLeft = daysBetween(today, deadline);
|
|
55
58
|
return {
|
|
56
59
|
...base,
|
|
@@ -3,7 +3,7 @@ import { categoryResolution, exploreCards, } from "./explore.js";
|
|
|
3
3
|
import { periodKey } from "./periods.js";
|
|
4
4
|
import { recommend } from "./recommend.js";
|
|
5
5
|
import { setupNotes } from "./setup.js";
|
|
6
|
-
|
|
6
|
+
const CHEAT_SHEET_ITEMS = [
|
|
7
7
|
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
8
8
|
{ label: 'Dining', category: 'dining' },
|
|
9
9
|
{ label: 'Gas', category: 'gas' },
|
|
@@ -22,7 +22,7 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
22
22
|
? resolveMerchantById(catalog, item.merchant, null)
|
|
23
23
|
: categoryResolution(catalog, item.category, null);
|
|
24
24
|
if (!merchant)
|
|
25
|
-
throw new Error(`unknown merchant '${item.label}
|
|
25
|
+
throw new Error(`unknown merchant '${'merchant' in item ? item.merchant : item.category}' for ${item.label}`);
|
|
26
26
|
const rec = recommend(catalog, wallet, {
|
|
27
27
|
merchant: merchant.input,
|
|
28
28
|
amount: null,
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -15,10 +15,19 @@ export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
|
15
15
|
}
|
|
16
16
|
// A category with one typical MCC (travel agencies are 4722) implies it, the
|
|
17
17
|
// same assumption explanations state.
|
|
18
|
-
function singleMcc(catalog, category) {
|
|
18
|
+
export function singleMcc(catalog, category) {
|
|
19
19
|
const mccs = catalog.categories.find((c) => c.id === category)?.mccs ?? [];
|
|
20
20
|
return mccs.length === 1 ? (mccs[0] ?? null) : null;
|
|
21
21
|
}
|
|
22
|
+
// A store card works only at its own stores; any other card wherever the
|
|
23
|
+
// merchant takes its network, which counts as yes when unknown.
|
|
24
|
+
export function usableAt(card, merchant) {
|
|
25
|
+
if (card.only_at)
|
|
26
|
+
return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
|
|
27
|
+
return (card.network === 'store' ||
|
|
28
|
+
merchant.accepts === null ||
|
|
29
|
+
merchant.accepts.includes(card.network));
|
|
30
|
+
}
|
|
22
31
|
export function categoryMatches(ruleCategory, category) {
|
|
23
32
|
return category === ruleCategory || category.startsWith(`${ruleCategory}.`);
|
|
24
33
|
}
|
|
@@ -47,7 +56,26 @@ export function pickedCategories(card, held, date) {
|
|
|
47
56
|
}
|
|
48
57
|
return { picked, staleChoice };
|
|
49
58
|
}
|
|
50
|
-
|
|
59
|
+
// Activating a rotating quarter (Freedom Flex) turns on all its categories:
|
|
60
|
+
// rules on the same rotating cap with the same dates share one activation.
|
|
61
|
+
export function activationGroup(card, ruleId) {
|
|
62
|
+
const rule = card.rules.find((r) => r.id === ruleId);
|
|
63
|
+
const cap = typeof rule?.cap === 'string'
|
|
64
|
+
? card.caps.find((c) => c.id === rule.cap)
|
|
65
|
+
: undefined;
|
|
66
|
+
if (!rule || !cap?.rotating)
|
|
67
|
+
return [ruleId];
|
|
68
|
+
return card.rules
|
|
69
|
+
.filter((r) => r.requires?.activation === true &&
|
|
70
|
+
r.cap === rule.cap &&
|
|
71
|
+
r.valid_from === rule.valid_from &&
|
|
72
|
+
r.valid_to === rule.valid_to)
|
|
73
|
+
.map((r) => r.id);
|
|
74
|
+
}
|
|
75
|
+
export function isActivated(card, held, ruleId) {
|
|
76
|
+
return activationGroup(card, ruleId).some((id) => held.activated.includes(id));
|
|
77
|
+
}
|
|
78
|
+
function applicability(card, rule, ctx, held, picked) {
|
|
51
79
|
const { match, exclude } = rule;
|
|
52
80
|
const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
|
|
53
81
|
if (match.categories?.length && !inCategory(match.categories))
|
|
@@ -89,24 +117,39 @@ function applicability(rule, ctx, held, picked) {
|
|
|
89
117
|
const membership = rule.requires?.membership;
|
|
90
118
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
91
119
|
return 'no';
|
|
92
|
-
if (rule.requires?.activation && !held
|
|
120
|
+
if (rule.requires?.activation && !isActivated(card, held, rule.id))
|
|
93
121
|
return 'needs-activation';
|
|
94
122
|
return 'applies';
|
|
95
123
|
}
|
|
124
|
+
// A cap's key in the wallet's cap_usage: the shared cap's id, or the rule's
|
|
125
|
+
// id for a cap written on the rule.
|
|
126
|
+
export function capByKey(card, key) {
|
|
127
|
+
const shared = card.caps.find((c) => c.id === key);
|
|
128
|
+
if (shared)
|
|
129
|
+
return shared;
|
|
130
|
+
const inline = card.rules.find((r) => r.id === key)?.cap;
|
|
131
|
+
return typeof inline === 'object' ? inline : undefined;
|
|
132
|
+
}
|
|
133
|
+
// How much of a cap has been spent in the period `date` falls in.
|
|
134
|
+
export function capUsed(cap, key, held, date) {
|
|
135
|
+
const period = periodKey(cap.period, date, held.anniversary ?? null);
|
|
136
|
+
const usage = held.cap_usage[key];
|
|
137
|
+
return {
|
|
138
|
+
period,
|
|
139
|
+
spent: period !== null && usage?.period === period ? usage.spent : 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
96
142
|
function capState(rule, card, held, date) {
|
|
97
143
|
if (rule.cap === undefined)
|
|
98
144
|
return null;
|
|
99
|
-
const
|
|
100
|
-
const cap =
|
|
101
|
-
if (cap === undefined
|
|
145
|
+
const key = typeof rule.cap === 'string' ? rule.cap : rule.id;
|
|
146
|
+
const cap = capByKey(card, key);
|
|
147
|
+
if (cap === undefined)
|
|
102
148
|
return null;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const usage = held.cap_usage[key];
|
|
106
|
-
const used = period !== null && usage?.period === period ? usage.spent : 0;
|
|
107
|
-
return { key, cap, period, remaining: Math.max(0, cap.amount - used) };
|
|
149
|
+
const { period, spent } = capUsed(cap, key, held, date);
|
|
150
|
+
return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
|
|
108
151
|
}
|
|
109
|
-
|
|
152
|
+
function rateAfterCap(rate, cap, amount) {
|
|
110
153
|
if (!cap)
|
|
111
154
|
return rate;
|
|
112
155
|
if (amount !== null && amount > 0) {
|
|
@@ -134,7 +177,7 @@ export function evaluateCard(card, held, ctx) {
|
|
|
134
177
|
let nominal = base;
|
|
135
178
|
const blocked = [];
|
|
136
179
|
for (const rule of card.rules) {
|
|
137
|
-
const status = applicability(rule, ctx, held, picked);
|
|
180
|
+
const status = applicability(card, rule, ctx, held, picked);
|
|
138
181
|
if (status === 'no')
|
|
139
182
|
continue;
|
|
140
183
|
if (status === 'needs-activation') {
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
3
|
-
import { periodKey } from "./periods.js";
|
|
4
|
-
import {
|
|
3
|
+
import { choicePeriod, periodKey } from "./periods.js";
|
|
4
|
+
import { recommend } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
6
|
import { membershipsUsed } from "./setup.js";
|
|
7
|
+
import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
|
|
7
8
|
import { cardValuation } from "./valuation.js";
|
|
8
9
|
const EPSILON = 1e-9;
|
|
9
10
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -29,18 +30,11 @@ export function matchesFilters(card, filters) {
|
|
|
29
30
|
return false;
|
|
30
31
|
return true;
|
|
31
32
|
}
|
|
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
33
|
// A card as its holder would set it up for the best result: the given picks,
|
|
40
34
|
// every activation done, and no cap used yet. The anniversary is `date`, so a
|
|
41
35
|
// cardmember-year cap starts fresh.
|
|
42
36
|
export function idealHolding(card, date, picks = {}) {
|
|
43
|
-
const period = choicePeriod(card);
|
|
37
|
+
const period = choicePeriod(card) ?? 'calendar_year';
|
|
44
38
|
return {
|
|
45
39
|
id: card.id,
|
|
46
40
|
anniversary: date.slice(5),
|
|
@@ -163,7 +157,7 @@ function conditionsText(catalog, r) {
|
|
|
163
157
|
if (r.membership !== null)
|
|
164
158
|
conditions.push(`Needs ${r.membership}.`);
|
|
165
159
|
if (r.cap) {
|
|
166
|
-
conditions.push(`Up to ${
|
|
160
|
+
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)}.`);
|
|
167
161
|
}
|
|
168
162
|
return conditions;
|
|
169
163
|
}
|
|
@@ -262,22 +256,15 @@ function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
|
262
256
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
263
257
|
}
|
|
264
258
|
else if (merchant.source !== 'none') {
|
|
265
|
-
|
|
266
|
-
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
259
|
+
assumptions.push(codesAs(catalog, merchant));
|
|
267
260
|
}
|
|
268
261
|
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.');
|
|
269
262
|
assumptions.push(options.memberships.length === 0
|
|
270
263
|
? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
|
|
271
264
|
: `Assumes these memberships: ${options.memberships.join(', ')}.`);
|
|
272
|
-
const values =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
values.set(e.currency, e.centsPerUnit);
|
|
276
|
-
}
|
|
277
|
-
if (values.size > 0) {
|
|
278
|
-
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
279
|
-
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
280
|
-
}
|
|
265
|
+
const values = pointValuesText(catalog, ranked.slice(0, 10));
|
|
266
|
+
if (values !== null)
|
|
267
|
+
assumptions.push(values);
|
|
281
268
|
return assumptions;
|
|
282
269
|
}
|
|
283
270
|
function exploreWarnings(catalog, merchant) {
|
package/dist/engine/offers.js
CHANGED
|
@@ -1,14 +1,7 @@
|
|
|
1
|
+
import { bonusDollars } from "./bonus.js";
|
|
2
|
+
import { matchesFilters } from "./explore.js";
|
|
3
|
+
import { roundCents } from "./text.js";
|
|
1
4
|
import { cardValuation } from "./valuation.js";
|
|
2
|
-
const round = (n) => Math.round(n * 100) / 100;
|
|
3
|
-
function bonusValue(card, centsPerUnit, points, dollars) {
|
|
4
|
-
if (points !== undefined)
|
|
5
|
-
return (points * centsPerUnit) / 100;
|
|
6
|
-
if (dollars === undefined)
|
|
7
|
-
return 0;
|
|
8
|
-
// A cash back card's bonus is paid like its rewards, so it pools when they
|
|
9
|
-
// do; a points card's dollar bonus is a statement credit.
|
|
10
|
-
return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
|
|
11
|
-
}
|
|
12
5
|
export function compareOffers(catalog, wallet, filters, today) {
|
|
13
6
|
const held = new Set(wallet.cards.map((c) => c.id));
|
|
14
7
|
const offers = [];
|
|
@@ -16,16 +9,9 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
16
9
|
for (const card of catalog.cards) {
|
|
17
10
|
if (card.network === 'store')
|
|
18
11
|
continue;
|
|
19
|
-
if (card.business !== filters.business)
|
|
20
|
-
continue;
|
|
21
12
|
if (!filters.includeHeld && held.has(card.id))
|
|
22
13
|
continue;
|
|
23
|
-
|
|
24
|
-
if (filters.kind === 'cash' && !isCash)
|
|
25
|
-
continue;
|
|
26
|
-
if (filters.kind === 'points' && isCash)
|
|
27
|
-
continue;
|
|
28
|
-
if (filters.noAnnualFee && card.annual_fee !== 0)
|
|
14
|
+
if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
|
|
29
15
|
continue;
|
|
30
16
|
const offer = card.welcome_offer;
|
|
31
17
|
if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
|
|
@@ -33,7 +19,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
33
19
|
continue;
|
|
34
20
|
}
|
|
35
21
|
const valuation = cardValuation(catalog, wallet, card);
|
|
36
|
-
const value =
|
|
22
|
+
const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
|
|
23
|
+
0;
|
|
37
24
|
const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
|
|
38
25
|
offers.push({
|
|
39
26
|
cardId: card.id,
|
|
@@ -47,11 +34,12 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
47
34
|
spend: offer.spend,
|
|
48
35
|
months: offer.months,
|
|
49
36
|
validTo: offer.valid_to ?? null,
|
|
37
|
+
varies: offer.varies === true,
|
|
50
38
|
note: offer.note ?? null,
|
|
51
39
|
annualFee: card.annual_fee ?? null,
|
|
52
40
|
firstYearFee,
|
|
53
|
-
bonusValue:
|
|
54
|
-
firstYearValue:
|
|
41
|
+
bonusValue: roundCents(value),
|
|
42
|
+
firstYearValue: roundCents(value - (firstYearFee ?? 0)),
|
|
55
43
|
held: held.has(card.id),
|
|
56
44
|
lastVerified: card.last_verified,
|
|
57
45
|
verifiedBy: card.verified_by,
|
|
@@ -60,7 +48,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
60
48
|
offers.sort((a, b) => b.firstYearValue - a.firstYearValue || a.cardId.localeCompare(b.cardId));
|
|
61
49
|
const notes = [
|
|
62
50
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
63
|
-
"Each offer is the public one on the issuer's page, checked weekly.
|
|
51
|
+
"Each offer is the public one on the issuer's page on the date shown, checked weekly. Yours may differ: issuers test and target offers, so use the one shown to you when you apply. Issuers also limit who can get a bonus; each card's note says how.",
|
|
64
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.",
|
|
65
53
|
];
|
|
66
54
|
return { offers, withoutOffer, notes };
|
package/dist/engine/periods.js
CHANGED
|
@@ -29,3 +29,12 @@ export function localToday(now = new Date()) {
|
|
|
29
29
|
String(now.getDate()).padStart(2, '0'),
|
|
30
30
|
].join('-');
|
|
31
31
|
}
|
|
32
|
+
// The period a card's picks are filed under in a wallet. Picks that carry
|
|
33
|
+
// over (until_changed) are never compared, so they file under the calendar
|
|
34
|
+
// year. null when the card's picks reset on different schedules.
|
|
35
|
+
export function choicePeriod(card) {
|
|
36
|
+
const periods = [
|
|
37
|
+
...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
|
|
38
|
+
];
|
|
39
|
+
return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
|
|
40
|
+
}
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
-
import { evaluateCard, purchaseContext, } from "./evaluate.js";
|
|
3
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
+
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
|
|
6
7
|
import { cardValuation } from "./valuation.js";
|
|
7
8
|
const EPSILON = 1e-9;
|
|
8
9
|
export const STALE_AFTER_DAYS = 180;
|
|
@@ -20,36 +21,9 @@ const PERIOD_WORDS = {
|
|
|
20
21
|
cardmember_year: 'cardmember-year',
|
|
21
22
|
};
|
|
22
23
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
23
|
-
export const num = (n) => String(Number(n.toFixed(2)));
|
|
24
|
-
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
|
|
25
|
-
const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
26
|
-
export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
|
|
27
|
-
export function currencyName(catalog, id) {
|
|
28
|
-
return catalog.currencies.find((c) => c.id === id)?.name ?? id;
|
|
29
|
-
}
|
|
30
|
-
export function currencyLabel(catalog, id) {
|
|
31
|
-
const name = currencyName(catalog, id);
|
|
32
|
-
return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
|
|
33
|
-
}
|
|
34
24
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
35
25
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
36
26
|
const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
|
|
37
|
-
function categoryName(catalog, id) {
|
|
38
|
-
return catalog.categories.find((c) => c.id === id)?.name ?? id;
|
|
39
|
-
}
|
|
40
|
-
// Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
|
|
41
|
-
// internet service" and "EV charging" keep their acronyms.
|
|
42
|
-
export function categoryPhrase(catalog, id) {
|
|
43
|
-
const name = categoryName(catalog, id);
|
|
44
|
-
return /^[A-Z]{2}/.test(name)
|
|
45
|
-
? name
|
|
46
|
-
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
47
|
-
}
|
|
48
|
-
export function earnText(catalog, rate, currency) {
|
|
49
|
-
return currency === 'cash'
|
|
50
|
-
? `${num(rate)}% cash back`
|
|
51
|
-
: `${num(rate)}x ${currencyName(catalog, currency)}`;
|
|
52
|
-
}
|
|
53
27
|
function compareEntries(a, b) {
|
|
54
28
|
const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
|
|
55
29
|
if (Math.abs(byReturn) > EPSILON)
|
|
@@ -71,14 +45,11 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
71
45
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
72
46
|
if (!card)
|
|
73
47
|
continue;
|
|
74
|
-
if (card
|
|
75
|
-
|
|
76
|
-
|
|
48
|
+
if (!usableAt(card, merchant)) {
|
|
49
|
+
// A store card elsewhere isn't worth mentioning; a network the merchant
|
|
50
|
+
// doesn't take is.
|
|
51
|
+
if (card.only_at || card.network === 'store' || !merchant.accepts)
|
|
77
52
|
continue;
|
|
78
|
-
}
|
|
79
|
-
else if (card.network !== 'store' &&
|
|
80
|
-
merchant.accepts &&
|
|
81
|
-
!merchant.accepts.includes(card.network)) {
|
|
82
53
|
const takes = merchant.accepts
|
|
83
54
|
.map((n) => NETWORK_NAMES[n] ?? n)
|
|
84
55
|
.join(' or ');
|
|
@@ -126,7 +97,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
126
97
|
const cap = best.cap;
|
|
127
98
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
128
99
|
const over = amount - cap.remaining;
|
|
129
|
-
lines.push(`${
|
|
100
|
+
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
130
101
|
}
|
|
131
102
|
if (valuation.pooledFrom !== null) {
|
|
132
103
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
@@ -147,7 +118,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
147
118
|
const { best, nominal } = evaluation;
|
|
148
119
|
const warnings = [];
|
|
149
120
|
const capLabel = (e) => e.cap
|
|
150
|
-
? `${
|
|
121
|
+
? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
|
|
151
122
|
: '';
|
|
152
123
|
if (best.cap && best.rule) {
|
|
153
124
|
const { remaining, cap, period } = best.cap;
|
|
@@ -155,10 +126,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
155
126
|
warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
|
|
156
127
|
}
|
|
157
128
|
else if (purchase.amount !== null && purchase.amount > remaining) {
|
|
158
|
-
warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${
|
|
129
|
+
warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
|
|
159
130
|
}
|
|
160
131
|
else if (remaining < cap.amount * NEAR_CAP_SHARE) {
|
|
161
|
-
warnings.push(`Only ${
|
|
132
|
+
warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
|
|
162
133
|
}
|
|
163
134
|
}
|
|
164
135
|
if (nominal.rule &&
|
|
@@ -265,11 +236,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
265
236
|
assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
|
|
266
237
|
}
|
|
267
238
|
else {
|
|
268
|
-
|
|
269
|
-
const mcc = merchant.likelyMcc ??
|
|
270
|
-
(category?.mccs.length === 1 ? category.mccs[0] : undefined);
|
|
271
|
-
const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
|
|
272
|
-
assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
|
|
239
|
+
assumptions.push(codesAs(catalog, merchant));
|
|
273
240
|
}
|
|
274
241
|
if (merchant.inference) {
|
|
275
242
|
assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
|
|
@@ -280,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
280
247
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
281
248
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
282
249
|
}
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
|
|
287
|
-
}
|
|
288
|
-
if (pointValues.size > 0) {
|
|
289
|
-
const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
290
|
-
assumptions.push(`Values points at: ${list.join(', ')}.`);
|
|
291
|
-
}
|
|
250
|
+
const values = pointValuesText(catalog, entries.map((e) => e.valuation));
|
|
251
|
+
if (values !== null)
|
|
252
|
+
assumptions.push(values);
|
|
292
253
|
return assumptions;
|
|
293
254
|
}
|
|
294
255
|
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
package/dist/engine/record.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
4
|
-
|
|
4
|
+
import { roundCents } from "./text.js";
|
|
5
5
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
6
6
|
// when that cap is already used up, so usage stays accurate across a reset.
|
|
7
7
|
// Returns null when there's nothing to track: no cap, or a cardmember-year cap
|
|
@@ -50,12 +50,8 @@ export function applyBooking(wallet, booking) {
|
|
|
50
50
|
: c),
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
-
export function recordPurchase(catalog, wallet, purchase) {
|
|
54
|
-
const booking = capBooking(catalog, wallet, purchase);
|
|
55
|
-
return booking ? applyBooking(wallet, booking) : wallet;
|
|
56
|
-
}
|
|
57
53
|
// The purchase's progress toward the card's open welcome bonus, if it counts.
|
|
58
|
-
|
|
54
|
+
function bonusBooking(catalog, wallet, purchase) {
|
|
59
55
|
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
60
56
|
const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
|
|
61
57
|
if (!status ||
|
|
@@ -79,7 +75,6 @@ export function applyBonusBooking(wallet, b) {
|
|
|
79
75
|
: c),
|
|
80
76
|
};
|
|
81
77
|
}
|
|
82
|
-
// Everything recording a purchase changes: its cap and its welcome bonus.
|
|
83
78
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
84
79
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
85
80
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pickedCategories } from "./evaluate.js";
|
|
1
|
+
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
2
|
import { daysBetween } from "./periods.js";
|
|
3
3
|
import { rotationGap } from "./rotation.js";
|
|
4
4
|
// Rules that start within this many days are worth activating now.
|
|
@@ -144,7 +144,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
144
144
|
text: `Some of ${name}'s rates need a membership: ${list(missing)}. Add any that apply to the wallet's memberships.`,
|
|
145
145
|
});
|
|
146
146
|
}
|
|
147
|
-
const toActivate = activationRules(card, today).filter((id) => !held
|
|
147
|
+
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
|
148
148
|
if (toActivate.length > 0) {
|
|
149
149
|
notes.push({
|
|
150
150
|
kind: 'activation',
|