earnrate 0.2.0 → 0.2.1
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 +22 -20
- 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 +68 -2
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +2 -2
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +9 -22
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +14 -53
- package/dist/engine/record.js +2 -7
- 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 +9 -15
- 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 +5 -14
- package/dist/{cli/format.js → format/recommendation.js} +2 -6
- package/dist/format/wallet.js +38 -0
- package/dist/import/analyze.js +17 -19
- package/dist/mcp/context.js +92 -0
- package/dist/mcp/inputs.js +148 -0
- package/dist/mcp/schemas.js +1 -1
- package/dist/mcp/server.js +14 -818
- package/dist/mcp/tools/account.js +71 -0
- package/dist/mcp/tools/answers.js +329 -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/package.json +2 -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/cli/wallet.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
+
import { FileWalletStore } from "../data/file-store.js";
|
|
3
4
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
4
|
-
import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
|
|
5
5
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
6
|
-
import { formatCardInfo, formatNotes } from "
|
|
6
|
+
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
7
7
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
8
8
|
const CARD_OPTIONS = `Options:
|
|
9
9
|
--label <text> How the card appears at checkout, e.g. "Amex Gold".
|
|
@@ -148,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
148
148
|
const catalog = await loadCatalog();
|
|
149
149
|
const file = walletPath(io.env);
|
|
150
150
|
const where = displayPath(file);
|
|
151
|
+
const store = new FileWalletStore(catalog, file);
|
|
151
152
|
if (command === 'cards') {
|
|
152
153
|
const query = positionals.join(' ');
|
|
153
154
|
const result = searchCards(catalog, query, await heldWallet(io), io.today);
|
|
@@ -169,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
169
170
|
return usage('Give memberships or --none, not both.');
|
|
170
171
|
}
|
|
171
172
|
const list = values.none ? [] : positionals;
|
|
172
|
-
return report(await setMemberships(
|
|
173
|
+
return report(await store.setMemberships(list), list.length > 0
|
|
173
174
|
? `Memberships set to ${list.join(', ')} in ${where}.`
|
|
174
175
|
: `Memberships cleared in ${where}.`, null, io);
|
|
175
176
|
}
|
|
@@ -179,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
179
180
|
const id = positionals[0] ?? '';
|
|
180
181
|
const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
181
182
|
if (command === 'remove') {
|
|
182
|
-
return report(await removeCard(
|
|
183
|
+
return report(await store.removeCard(id), `Removed ${name} from ${where}.`, null, io);
|
|
183
184
|
}
|
|
184
185
|
const input = { id };
|
|
185
186
|
if (values.label !== undefined)
|
|
@@ -227,8 +228,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
227
228
|
return usage('Give --bonus-opened with the other --bonus options.');
|
|
228
229
|
}
|
|
229
230
|
if (command === 'add') {
|
|
230
|
-
const result = await addCard(
|
|
231
|
+
const result = await store.addCard(input, io.today);
|
|
231
232
|
return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
|
|
232
233
|
}
|
|
233
|
-
return report(await updateCard(
|
|
234
|
+
return report(await store.updateCard(input, io.today), `Updated ${name} in ${where}.`, id, io);
|
|
234
235
|
}
|
package/dist/data/file-store.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
3
|
import { loadWallet } from "./load.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
5
|
+
const failed = (error) => ({ ok: false, error });
|
|
6
|
+
const errorText = (e) => (e instanceof Error ? e.message : String(e));
|
|
7
|
+
const queues = new Map();
|
|
8
|
+
// Runs `task` after every earlier task for the same file has settled, so a
|
|
9
|
+
// read-modify-write of the wallet never interleaves with another.
|
|
10
|
+
function withWalletLock(file, task) {
|
|
11
|
+
const previous = queues.get(file) ?? Promise.resolve();
|
|
12
|
+
const run = previous.then(task, task);
|
|
13
|
+
queues.set(file, run.catch(() => undefined));
|
|
14
|
+
return run;
|
|
15
|
+
}
|
|
16
|
+
// The wallet as a YAML file (the CLI, the local MCP server and the .mcpb).
|
|
17
|
+
// Each change is worked out on the parsed wallet, then spliced into the text,
|
|
18
|
+
// which must re-parse to exactly that wallet before it's written, so the
|
|
19
|
+
// user's comments survive.
|
|
7
20
|
export class FileWalletStore {
|
|
8
21
|
where;
|
|
9
22
|
catalog;
|
|
@@ -21,34 +34,60 @@ export class FileWalletStore {
|
|
|
21
34
|
throw e;
|
|
22
35
|
}
|
|
23
36
|
}
|
|
37
|
+
change(plan, edit) {
|
|
38
|
+
return withWalletLock(this.where, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const { text, wallet } = await readWalletText(this.where);
|
|
41
|
+
const change = plan(wallet);
|
|
42
|
+
if (typeof change === 'string')
|
|
43
|
+
return failed(change);
|
|
44
|
+
if (!change.ok)
|
|
45
|
+
return change;
|
|
46
|
+
await commitWalletText(this.where, edit(text, change), change.wallet);
|
|
47
|
+
return {
|
|
48
|
+
ok: true,
|
|
49
|
+
wallet: change.wallet,
|
|
50
|
+
created: text === null,
|
|
51
|
+
notes: change.notes,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return failed(errorText(e));
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Changes other than adding a card need a wallet file to change.
|
|
60
|
+
existing(apply) {
|
|
61
|
+
return (wallet) => wallet === null
|
|
62
|
+
? `No wallet at ${this.where} yet. Add a card first to create it.`
|
|
63
|
+
: apply(wallet);
|
|
64
|
+
}
|
|
24
65
|
addCard(input, today) {
|
|
25
|
-
return
|
|
66
|
+
return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
|
|
26
67
|
}
|
|
27
68
|
updateCard(input, today) {
|
|
28
|
-
return
|
|
69
|
+
return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
|
|
29
70
|
}
|
|
30
71
|
removeCard(cardId) {
|
|
31
|
-
return
|
|
72
|
+
return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
32
73
|
}
|
|
33
74
|
setMemberships(memberships) {
|
|
34
|
-
return
|
|
75
|
+
return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
|
|
35
76
|
}
|
|
36
77
|
record(purchase, merchant) {
|
|
37
78
|
return withWalletLock(this.where, async () => {
|
|
38
79
|
const wallet = await this.load();
|
|
39
80
|
if (!wallet)
|
|
40
|
-
return
|
|
81
|
+
return failed("There's no saved wallet to record it in.");
|
|
41
82
|
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
42
|
-
return
|
|
83
|
+
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
43
84
|
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
44
85
|
try {
|
|
45
|
-
if (booked.cap)
|
|
46
|
-
await
|
|
47
|
-
if (booked.bonus)
|
|
48
|
-
await writeBonusSpent(this.where, purchase.cardId, booked.bonus.after);
|
|
86
|
+
if (booked.cap || booked.bonus)
|
|
87
|
+
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
|
|
49
88
|
}
|
|
50
89
|
catch (e) {
|
|
51
|
-
return
|
|
90
|
+
return failed(errorText(e));
|
|
52
91
|
}
|
|
53
92
|
return { ok: true, ...booked };
|
|
54
93
|
});
|
package/dist/data/load.js
CHANGED
|
@@ -11,7 +11,7 @@ import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
|
11
11
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
12
12
|
import { WalletSchema } from "../schema/wallet.js";
|
|
13
13
|
export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
|
|
14
|
-
|
|
14
|
+
async function readYaml(file, schema) {
|
|
15
15
|
const raw = parse(await readFile(file, 'utf8'));
|
|
16
16
|
const result = schema.safeParse(raw);
|
|
17
17
|
if (!result.success) {
|
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
|
|
@@ -154,6 +155,71 @@ export function setMembershipsText(text, memberships) {
|
|
|
154
155
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
155
156
|
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
156
157
|
}
|
|
158
|
+
function flowUsage(booking) {
|
|
159
|
+
const period = /^\d+$/.test(booking.period)
|
|
160
|
+
? `"${booking.period}"`
|
|
161
|
+
: booking.period;
|
|
162
|
+
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
163
|
+
}
|
|
164
|
+
export function capUsageText(text, booking) {
|
|
165
|
+
const card = findCard(text, booking.cardId);
|
|
166
|
+
const value = flowUsage(booking);
|
|
167
|
+
const usage = card.get('cap_usage', true);
|
|
168
|
+
if (isMap(usage) && !usage.flow && usage.items.length > 0) {
|
|
169
|
+
const existing = usage.items.find((p) => keyOf(p) === booking.capKey);
|
|
170
|
+
if (existing) {
|
|
171
|
+
const [start, end] = range(existing.value);
|
|
172
|
+
return text.slice(0, start) + value + text.slice(end);
|
|
173
|
+
}
|
|
174
|
+
const indent = ' '.repeat(column(text, range(usage.items[0]?.key)[0]));
|
|
175
|
+
const at = afterLine(text, range(usage)[1] - 1);
|
|
176
|
+
return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
|
|
177
|
+
}
|
|
178
|
+
if (usage !== undefined)
|
|
179
|
+
throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; ${BY_HAND}`);
|
|
180
|
+
const indent = ' '.repeat(column(text, range(card.items[0]?.key)[0]));
|
|
181
|
+
const at = afterLine(text, range(card)[1] - 1);
|
|
182
|
+
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
183
|
+
}
|
|
184
|
+
export function bonusSpentText(text, cardId, spent) {
|
|
185
|
+
const bonus = findCard(text, cardId).get('bonus', true);
|
|
186
|
+
if (!isMap(bonus))
|
|
187
|
+
throw new Error(`card '${cardId}' has no bonus`);
|
|
188
|
+
const value = String(spent);
|
|
189
|
+
const existing = bonus.items.find((p) => keyOf(p) === 'spent');
|
|
190
|
+
if (existing) {
|
|
191
|
+
const [start, end] = range(existing.value);
|
|
192
|
+
return text.slice(0, start) + value + text.slice(end);
|
|
193
|
+
}
|
|
194
|
+
if (bonus.flow) {
|
|
195
|
+
const close = text.lastIndexOf('}', range(bonus)[1]);
|
|
196
|
+
return `${text.slice(0, close).trimEnd()}, spent: ${value} ${text.slice(close)}`;
|
|
197
|
+
}
|
|
198
|
+
const indent = ' '.repeat(column(text, range(bonus.items[0]?.key)[0]));
|
|
199
|
+
const at = afterLine(text, range(bonus)[1] - 1);
|
|
200
|
+
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
201
|
+
}
|
|
202
|
+
// Writes spending toward caps and welcome bonuses in one checked write.
|
|
203
|
+
export async function writeProgress(file, caps, bonuses) {
|
|
204
|
+
const { text, wallet } = await readWalletText(file);
|
|
205
|
+
if (text === null || wallet === null)
|
|
206
|
+
throw new Error(`No wallet at ${file}.`);
|
|
207
|
+
let after = text;
|
|
208
|
+
let expected = wallet;
|
|
209
|
+
for (const b of caps) {
|
|
210
|
+
after = capUsageText(after, b);
|
|
211
|
+
expected = applyBooking(expected, b);
|
|
212
|
+
}
|
|
213
|
+
for (const b of bonuses) {
|
|
214
|
+
after = bonusSpentText(after, b.cardId, b.after);
|
|
215
|
+
expected = applyBonusBooking(expected, b);
|
|
216
|
+
}
|
|
217
|
+
const byHand = [
|
|
218
|
+
...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b)}`),
|
|
219
|
+
...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
|
|
220
|
+
].join('; ');
|
|
221
|
+
await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
|
|
222
|
+
}
|
|
157
223
|
async function readText(file) {
|
|
158
224
|
try {
|
|
159
225
|
return await readFile(file, 'utf8');
|
|
@@ -173,10 +239,10 @@ export async function readWalletText(file) {
|
|
|
173
239
|
}
|
|
174
240
|
// Writes `after` only if it parses to exactly `expected`, so a splice that
|
|
175
241
|
// went wrong can never change anything it wasn't meant to.
|
|
176
|
-
export async function commitWalletText(file, after, expected) {
|
|
242
|
+
export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
|
|
177
243
|
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
178
244
|
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
179
|
-
throw new Error(`couldn't update ${file} safely; ${
|
|
245
|
+
throw new Error(`couldn't update ${file} safely; ${fallback}`);
|
|
180
246
|
}
|
|
181
247
|
await mkdir(path.dirname(file), { recursive: true });
|
|
182
248
|
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
|
}
|
|
@@ -93,20 +102,35 @@ function applicability(rule, ctx, held, picked) {
|
|
|
93
102
|
return 'needs-activation';
|
|
94
103
|
return 'applies';
|
|
95
104
|
}
|
|
105
|
+
// A cap's key in the wallet's cap_usage: the shared cap's id, or the rule's
|
|
106
|
+
// id for a cap written on the rule.
|
|
107
|
+
export function capByKey(card, key) {
|
|
108
|
+
const shared = card.caps.find((c) => c.id === key);
|
|
109
|
+
if (shared)
|
|
110
|
+
return shared;
|
|
111
|
+
const inline = card.rules.find((r) => r.id === key)?.cap;
|
|
112
|
+
return typeof inline === 'object' ? inline : undefined;
|
|
113
|
+
}
|
|
114
|
+
// How much of a cap has been spent in the period `date` falls in.
|
|
115
|
+
export function capUsed(cap, key, held, date) {
|
|
116
|
+
const period = periodKey(cap.period, date, held.anniversary ?? null);
|
|
117
|
+
const usage = held.cap_usage[key];
|
|
118
|
+
return {
|
|
119
|
+
period,
|
|
120
|
+
spent: period !== null && usage?.period === period ? usage.spent : 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
96
123
|
function capState(rule, card, held, date) {
|
|
97
124
|
if (rule.cap === undefined)
|
|
98
125
|
return null;
|
|
99
|
-
const
|
|
100
|
-
const cap =
|
|
101
|
-
if (cap === undefined
|
|
126
|
+
const key = typeof rule.cap === 'string' ? rule.cap : rule.id;
|
|
127
|
+
const cap = capByKey(card, key);
|
|
128
|
+
if (cap === undefined)
|
|
102
129
|
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) };
|
|
130
|
+
const { period, spent } = capUsed(cap, key, held, date);
|
|
131
|
+
return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
|
|
108
132
|
}
|
|
109
|
-
|
|
133
|
+
function rateAfterCap(rate, cap, amount) {
|
|
110
134
|
if (!cap)
|
|
111
135
|
return rate;
|
|
112
136
|
if (amount !== null && amount > 0) {
|
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,
|
|
@@ -50,8 +37,8 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
50
37
|
note: offer.note ?? null,
|
|
51
38
|
annualFee: card.annual_fee ?? null,
|
|
52
39
|
firstYearFee,
|
|
53
|
-
bonusValue:
|
|
54
|
-
firstYearValue:
|
|
40
|
+
bonusValue: roundCents(value),
|
|
41
|
+
firstYearValue: roundCents(value - (firstYearFee ?? 0)),
|
|
55
42
|
held: held.has(card.id),
|
|
56
43
|
lastVerified: card.last_verified,
|
|
57
44
|
verifiedBy: card.verified_by,
|
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
|
+
}
|