earnrate 0.1.4 → 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 +52 -17
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/brand.js +2 -3
- package/dist/cli/args.js +67 -45
- package/dist/cli/cheatsheet.js +36 -35
- package/dist/cli/explore.js +47 -163
- package/dist/cli/import.js +3 -8
- package/dist/cli/offers.js +14 -56
- package/dist/cli/run.js +90 -20
- package/dist/cli/wallet.js +47 -6
- package/dist/data/file-store.js +95 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +92 -0
- package/dist/data/wallet-edit.js +93 -4
- package/dist/engine/bonus.js +15 -12
- package/dist/engine/cheat-sheet.js +59 -4
- package/dist/engine/evaluate.js +34 -10
- package/dist/engine/explore.js +16 -28
- package/dist/engine/offers.js +8 -21
- package/dist/engine/periods.js +9 -0
- package/dist/engine/recommend.js +17 -53
- package/dist/engine/record.js +34 -4
- package/dist/engine/simulate.js +33 -19
- package/dist/engine/text.js +53 -0
- package/dist/engine/transient.js +26 -0
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +36 -15
- package/dist/engine/wallet-summary.js +4 -8
- package/dist/format/card-text.js +79 -0
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +114 -0
- package/dist/format/offers.js +33 -0
- package/dist/{cli/format.js → format/recommendation.js} +9 -11
- package/dist/format/wallet-yaml.js +45 -0
- 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 +8 -2
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +43 -660
- 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/dist/schema/catalog.js +1 -0
- package/package.json +8 -3
- 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".
|
|
@@ -13,6 +13,13 @@ const CARD_OPTIONS = `Options:
|
|
|
13
13
|
--anniversary <MM-DD> Account anniversary, for caps that reset on it.
|
|
14
14
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
15
15
|
separated. Replaces the current list.
|
|
16
|
+
--bonus-opened <date> Track a welcome bonus: the day the account opened
|
|
17
|
+
(YYYY-MM-DD). Spending needed and the deadline
|
|
18
|
+
default to the card's published offer.
|
|
19
|
+
--bonus-spent <dollars> Spending toward it so far. Default 0.
|
|
20
|
+
--bonus-spend <dollars> The spending needed, if your offer differs.
|
|
21
|
+
--bonus-by <date> The deadline, if your offer differs.
|
|
22
|
+
--no-bonus Stop tracking the welcome bonus.
|
|
16
23
|
-h, --help Show this help.`;
|
|
17
24
|
export const WALLET_HELP = {
|
|
18
25
|
cards: `Usage: ${CLI_NAME} cards [search words]
|
|
@@ -105,6 +112,11 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
105
112
|
pick: { type: 'string', multiple: true },
|
|
106
113
|
anniversary: { type: 'string' },
|
|
107
114
|
activated: { type: 'string' },
|
|
115
|
+
'bonus-opened': { type: 'string' },
|
|
116
|
+
'bonus-spent': { type: 'string' },
|
|
117
|
+
'bonus-spend': { type: 'string' },
|
|
118
|
+
'bonus-by': { type: 'string' },
|
|
119
|
+
'no-bonus': { type: 'boolean' },
|
|
108
120
|
none: { type: 'boolean' },
|
|
109
121
|
help: { type: 'boolean', short: 'h' },
|
|
110
122
|
},
|
|
@@ -136,6 +148,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
136
148
|
const catalog = await loadCatalog();
|
|
137
149
|
const file = walletPath(io.env);
|
|
138
150
|
const where = displayPath(file);
|
|
151
|
+
const store = new FileWalletStore(catalog, file);
|
|
139
152
|
if (command === 'cards') {
|
|
140
153
|
const query = positionals.join(' ');
|
|
141
154
|
const result = searchCards(catalog, query, await heldWallet(io), io.today);
|
|
@@ -157,7 +170,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
157
170
|
return usage('Give memberships or --none, not both.');
|
|
158
171
|
}
|
|
159
172
|
const list = values.none ? [] : positionals;
|
|
160
|
-
return report(await setMemberships(
|
|
173
|
+
return report(await store.setMemberships(list), list.length > 0
|
|
161
174
|
? `Memberships set to ${list.join(', ')} in ${where}.`
|
|
162
175
|
: `Memberships cleared in ${where}.`, null, io);
|
|
163
176
|
}
|
|
@@ -167,7 +180,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
167
180
|
const id = positionals[0] ?? '';
|
|
168
181
|
const name = catalog.cards.find((c) => c.id === id)?.name ?? id;
|
|
169
182
|
if (command === 'remove') {
|
|
170
|
-
return report(await removeCard(
|
|
183
|
+
return report(await store.removeCard(id), `Removed ${name} from ${where}.`, null, io);
|
|
171
184
|
}
|
|
172
185
|
const input = { id };
|
|
173
186
|
if (values.label !== undefined)
|
|
@@ -186,9 +199,37 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
186
199
|
return usage(picks);
|
|
187
200
|
input.picks = picks;
|
|
188
201
|
}
|
|
202
|
+
const dollarsFlag = (flag, text) => {
|
|
203
|
+
if (text === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
const n = Number(text.replace(/[$,]/g, ''));
|
|
206
|
+
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
207
|
+
};
|
|
208
|
+
if (values['no-bonus']) {
|
|
209
|
+
input.bonus = null;
|
|
210
|
+
}
|
|
211
|
+
else if (values['bonus-opened'] !== undefined) {
|
|
212
|
+
const spent = dollarsFlag('bonus-spent', values['bonus-spent']);
|
|
213
|
+
const spend = dollarsFlag('bonus-spend', values['bonus-spend']);
|
|
214
|
+
if (typeof spent === 'string')
|
|
215
|
+
return usage(spent);
|
|
216
|
+
if (typeof spend === 'string')
|
|
217
|
+
return usage(spend);
|
|
218
|
+
input.bonus = {
|
|
219
|
+
opened: values['bonus-opened'],
|
|
220
|
+
...(spent === undefined ? {} : { spent }),
|
|
221
|
+
...(spend === undefined ? {} : { spend }),
|
|
222
|
+
...(values['bonus-by'] === undefined ? {} : { by: values['bonus-by'] }),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
else if (values['bonus-spent'] !== undefined ||
|
|
226
|
+
values['bonus-spend'] !== undefined ||
|
|
227
|
+
values['bonus-by'] !== undefined) {
|
|
228
|
+
return usage('Give --bonus-opened with the other --bonus options.');
|
|
229
|
+
}
|
|
189
230
|
if (command === 'add') {
|
|
190
|
-
const result = await addCard(
|
|
231
|
+
const result = await store.addCard(input, io.today);
|
|
191
232
|
return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
|
|
192
233
|
}
|
|
193
|
-
return report(await updateCard(
|
|
234
|
+
return report(await store.updateCard(input, io.today), `Updated ${name} in ${where}.`, id, io);
|
|
194
235
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { bookPurchase } from "../engine/record.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
3
|
+
import { loadWallet } from "./load.js";
|
|
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.
|
|
20
|
+
export class FileWalletStore {
|
|
21
|
+
where;
|
|
22
|
+
catalog;
|
|
23
|
+
constructor(catalog, file) {
|
|
24
|
+
this.catalog = catalog;
|
|
25
|
+
this.where = file;
|
|
26
|
+
}
|
|
27
|
+
async load() {
|
|
28
|
+
try {
|
|
29
|
+
return await loadWallet(this.where);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
|
|
33
|
+
return null;
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
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
|
+
}
|
|
65
|
+
addCard(input, today) {
|
|
66
|
+
return this.change((wallet) => addToWallet(this.catalog, wallet, input, today), (text, change) => addCardText(text, { id: input.id, ...change.fields }));
|
|
67
|
+
}
|
|
68
|
+
updateCard(input, today) {
|
|
69
|
+
return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
|
|
70
|
+
}
|
|
71
|
+
removeCard(cardId) {
|
|
72
|
+
return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
73
|
+
}
|
|
74
|
+
setMemberships(memberships) {
|
|
75
|
+
return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
|
|
76
|
+
}
|
|
77
|
+
record(purchase, merchant) {
|
|
78
|
+
return withWalletLock(this.where, async () => {
|
|
79
|
+
const wallet = await this.load();
|
|
80
|
+
if (!wallet)
|
|
81
|
+
return failed("There's no saved wallet to record it in.");
|
|
82
|
+
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
83
|
+
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
84
|
+
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
85
|
+
try {
|
|
86
|
+
if (booked.cap || booked.bonus)
|
|
87
|
+
await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : []);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return failed(errorText(e));
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, ...booked };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
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) {
|
|
@@ -39,8 +39,16 @@ export async function loadCatalog(dir = DATA_DIR) {
|
|
|
39
39
|
]);
|
|
40
40
|
return { categories, currencies, cards, merchants: { seed, overrides } };
|
|
41
41
|
}
|
|
42
|
+
// MCP clients pass config values through without a shell, so "~" or
|
|
43
|
+
// "${HOME}" can arrive as literal text; expand them here.
|
|
42
44
|
export function walletPath(env = process.env) {
|
|
43
|
-
|
|
45
|
+
const given = env[WALLET_ENV_VAR]?.trim();
|
|
46
|
+
if (!given)
|
|
47
|
+
return path.join(homedir(), CONFIG_DIR, 'wallet.yaml');
|
|
48
|
+
const expanded = given
|
|
49
|
+
.replace(/^~(?=$|[/\\])/, homedir())
|
|
50
|
+
.replace(/\$\{HOME\}|\$HOME\b/g, homedir());
|
|
51
|
+
return path.resolve(expanded);
|
|
44
52
|
}
|
|
45
53
|
export function loadWallet(file = walletPath()) {
|
|
46
54
|
return readYaml(file, WalletSchema);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { bookPurchase } from "../engine/record.js";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
3
|
+
import { WalletSchema } from "../schema/wallet.js";
|
|
4
|
+
const failed = (error) => ({ ok: false, error });
|
|
5
|
+
// A wallet stored as JSON text. Writes are serialized within this instance;
|
|
6
|
+
// a backend shared across processes must serialize them itself (one Durable
|
|
7
|
+
// Object per wallet does).
|
|
8
|
+
export class JsonWalletStore {
|
|
9
|
+
where;
|
|
10
|
+
catalog;
|
|
11
|
+
backend;
|
|
12
|
+
queue = Promise.resolve();
|
|
13
|
+
constructor(catalog, backend, where) {
|
|
14
|
+
this.catalog = catalog;
|
|
15
|
+
this.backend = backend;
|
|
16
|
+
this.where = where;
|
|
17
|
+
}
|
|
18
|
+
async load() {
|
|
19
|
+
const text = await this.backend.read();
|
|
20
|
+
return text === null ? null : WalletSchema.parse(JSON.parse(text));
|
|
21
|
+
}
|
|
22
|
+
serialized(task) {
|
|
23
|
+
const run = this.queue.then(task, task);
|
|
24
|
+
this.queue = run.catch(() => undefined);
|
|
25
|
+
return run;
|
|
26
|
+
}
|
|
27
|
+
change(apply) {
|
|
28
|
+
return this.serialized(async () => {
|
|
29
|
+
const before = await this.load();
|
|
30
|
+
const change = apply(before);
|
|
31
|
+
if (!change.ok)
|
|
32
|
+
return failed(change.error);
|
|
33
|
+
await this.backend.write(JSON.stringify(change.wallet));
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
wallet: change.wallet,
|
|
37
|
+
created: before === null,
|
|
38
|
+
notes: change.notes,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
needsWallet(apply) {
|
|
43
|
+
return (wallet) => wallet === null
|
|
44
|
+
? failed("There's no saved wallet yet. Add a card first to start one.")
|
|
45
|
+
: apply(wallet);
|
|
46
|
+
}
|
|
47
|
+
addCard(input, today) {
|
|
48
|
+
return this.change((w) => addToWallet(this.catalog, w, input, today));
|
|
49
|
+
}
|
|
50
|
+
updateCard(input, today) {
|
|
51
|
+
return this.change(this.needsWallet((w) => updateInWallet(this.catalog, w, input, today)));
|
|
52
|
+
}
|
|
53
|
+
removeCard(cardId) {
|
|
54
|
+
return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
|
|
55
|
+
}
|
|
56
|
+
setMemberships(memberships) {
|
|
57
|
+
return this.change(this.needsWallet((w) => withMemberships(this.catalog, w, memberships)));
|
|
58
|
+
}
|
|
59
|
+
replace(wallet) {
|
|
60
|
+
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
61
|
+
}
|
|
62
|
+
erase() {
|
|
63
|
+
return this.serialized(() => this.backend.remove());
|
|
64
|
+
}
|
|
65
|
+
record(purchase, merchant) {
|
|
66
|
+
return this.serialized(async () => {
|
|
67
|
+
const wallet = await this.load();
|
|
68
|
+
if (!wallet)
|
|
69
|
+
return failed("There's no saved wallet to record it in.");
|
|
70
|
+
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
71
|
+
return failed(`${purchase.cardId} isn't in the wallet.`);
|
|
72
|
+
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
73
|
+
if (booked.cap || booked.bonus)
|
|
74
|
+
await this.backend.write(JSON.stringify(booked.wallet));
|
|
75
|
+
return { ok: true, ...booked };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function memoryBackend(initial = null) {
|
|
80
|
+
let text = initial;
|
|
81
|
+
return {
|
|
82
|
+
read: () => Promise.resolve(text),
|
|
83
|
+
write: (next) => {
|
|
84
|
+
text = next;
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
},
|
|
87
|
+
remove: () => {
|
|
88
|
+
text = null;
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
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
|
|
@@ -38,6 +39,18 @@ function fieldLines(field, f, indent) {
|
|
|
38
39
|
return f.activated === undefined
|
|
39
40
|
? ''
|
|
40
41
|
: `${indent}activated: ${flowList(f.activated)}\n`;
|
|
42
|
+
case 'bonus': {
|
|
43
|
+
if (!f.bonus)
|
|
44
|
+
return '';
|
|
45
|
+
const b = f.bonus;
|
|
46
|
+
return [
|
|
47
|
+
`${indent}bonus:\n`,
|
|
48
|
+
`${indent} opened: ${scalar(b.opened)}\n`,
|
|
49
|
+
`${indent} spent: ${String(b.spent)}\n`,
|
|
50
|
+
b.spend === undefined ? '' : `${indent} spend: ${String(b.spend)}\n`,
|
|
51
|
+
b.by === undefined ? '' : `${indent} by: ${scalar(b.by)}\n`,
|
|
52
|
+
].join('');
|
|
53
|
+
}
|
|
41
54
|
case 'chosen': {
|
|
42
55
|
if (f.chosen === undefined)
|
|
43
56
|
return '';
|
|
@@ -48,7 +61,13 @@ function fieldLines(field, f, indent) {
|
|
|
48
61
|
}
|
|
49
62
|
}
|
|
50
63
|
}
|
|
51
|
-
const FIELDS = [
|
|
64
|
+
const FIELDS = [
|
|
65
|
+
'checkout_label',
|
|
66
|
+
'anniversary',
|
|
67
|
+
'chosen',
|
|
68
|
+
'activated',
|
|
69
|
+
'bonus',
|
|
70
|
+
];
|
|
52
71
|
function cardsSeq(text) {
|
|
53
72
|
const doc = parseDocument(text);
|
|
54
73
|
if (doc.errors.length > 0)
|
|
@@ -105,7 +124,12 @@ export function updateCardText(text, cardId, fields) {
|
|
|
105
124
|
const indent = ' '.repeat(column(out, range(card)[0]));
|
|
106
125
|
const lines = fieldLines(field, fields, indent);
|
|
107
126
|
const pair = card.items.find((p) => keyOf(p) === field);
|
|
108
|
-
if (pair) {
|
|
127
|
+
if (pair && lines === '') {
|
|
128
|
+
const start = lineStart(out, range(pair.key)[0]);
|
|
129
|
+
const end = afterLine(out, range(pair.value)[1] - 1);
|
|
130
|
+
out = out.slice(0, start) + out.slice(end);
|
|
131
|
+
}
|
|
132
|
+
else if (pair) {
|
|
109
133
|
const start = range(pair.key)[0];
|
|
110
134
|
const end = afterLine(out, range(pair.value)[1] - 1);
|
|
111
135
|
out = out.slice(0, start) + lines.trimStart() + out.slice(end);
|
|
@@ -131,6 +155,71 @@ export function setMembershipsText(text, memberships) {
|
|
|
131
155
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
132
156
|
return insertLines(text, lineStart(text, range(cards?.key)[0]), line);
|
|
133
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
|
+
}
|
|
134
223
|
async function readText(file) {
|
|
135
224
|
try {
|
|
136
225
|
return await readFile(file, 'utf8');
|
|
@@ -150,10 +239,10 @@ export async function readWalletText(file) {
|
|
|
150
239
|
}
|
|
151
240
|
// Writes `after` only if it parses to exactly `expected`, so a splice that
|
|
152
241
|
// went wrong can never change anything it wasn't meant to.
|
|
153
|
-
export async function commitWalletText(file, after, expected) {
|
|
242
|
+
export async function commitWalletText(file, after, expected, fallback = BY_HAND) {
|
|
154
243
|
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
155
244
|
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
156
|
-
throw new Error(`couldn't update ${file} safely; ${
|
|
245
|
+
throw new Error(`couldn't update ${file} safely; ${fallback}`);
|
|
157
246
|
}
|
|
158
247
|
await mkdir(path.dirname(file), { recursive: true });
|
|
159
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,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryResolution } from "./explore.js";
|
|
2
|
+
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,
|
|
@@ -44,5 +44,60 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
44
44
|
...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
|
|
45
45
|
];
|
|
46
46
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
47
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
scope: 'wallet',
|
|
49
|
+
quarter: `${q ?? ''} ${year ?? ''}`,
|
|
50
|
+
date,
|
|
51
|
+
rows,
|
|
52
|
+
notes,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// The best card earnrate knows for each everyday kind of purchase, for
|
|
56
|
+
// someone who hasn't named any cards. Each row's note says what the card
|
|
57
|
+
// costs or needs.
|
|
58
|
+
export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_ITEMS) {
|
|
59
|
+
const rows = items.map((item) => {
|
|
60
|
+
const top = exploreCards(catalog, 'merchant' in item
|
|
61
|
+
? {
|
|
62
|
+
merchant: catalog.merchants.seed.find((m) => m.id === item.merchant)
|
|
63
|
+
?.name ??
|
|
64
|
+
catalog.merchants.overrides.find((m) => m.id === item.merchant)
|
|
65
|
+
?.name ??
|
|
66
|
+
item.label,
|
|
67
|
+
category: null,
|
|
68
|
+
amount: null,
|
|
69
|
+
channel: null,
|
|
70
|
+
date,
|
|
71
|
+
}
|
|
72
|
+
: {
|
|
73
|
+
merchant: null,
|
|
74
|
+
category: item.category,
|
|
75
|
+
amount: null,
|
|
76
|
+
channel: null,
|
|
77
|
+
date,
|
|
78
|
+
}, options).ranked[0];
|
|
79
|
+
const fee = top?.annualFee === null || top?.annualFee === undefined
|
|
80
|
+
? null
|
|
81
|
+
: top.annualFee === 0
|
|
82
|
+
? 'No annual fee.'
|
|
83
|
+
: `$${String(top.annualFee)} annual fee.`;
|
|
84
|
+
return {
|
|
85
|
+
label: item.label,
|
|
86
|
+
cardId: top?.cardId ?? null,
|
|
87
|
+
cardName: top?.cardName ?? null,
|
|
88
|
+
effectiveReturnPct: top?.effectiveReturnPct ?? null,
|
|
89
|
+
matchedRule: top?.matchedRule ?? null,
|
|
90
|
+
note: top
|
|
91
|
+
? [fee, ...top.conditions].filter((n) => n !== null).join(' ')
|
|
92
|
+
: null,
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
96
|
+
return {
|
|
97
|
+
scope: 'all cards',
|
|
98
|
+
quarter: `${q ?? ''} ${year ?? ''}`,
|
|
99
|
+
date,
|
|
100
|
+
rows,
|
|
101
|
+
notes: [],
|
|
102
|
+
};
|
|
48
103
|
}
|
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) {
|