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
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
2
|
-
import { addCardText, commitWalletText, readWalletText, removeCardText, setMembershipsText, updateCardText, } from "./wallet-edit.js";
|
|
3
|
-
const failed = (error) => ({ ok: false, error });
|
|
4
|
-
async function attempt(task) {
|
|
5
|
-
try {
|
|
6
|
-
return await task();
|
|
7
|
-
}
|
|
8
|
-
catch (e) {
|
|
9
|
-
return failed(e instanceof Error ? e.message : String(e));
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
const noWallet = (file) => `No wallet at ${file} yet. Add a card first to create it.`;
|
|
13
|
-
// The change is worked out on the parsed wallet, then spliced into the file
|
|
14
|
-
// text, which must re-parse to exactly that wallet before it's written.
|
|
15
|
-
async function commit(file, edited, change, created) {
|
|
16
|
-
await commitWalletText(file, edited, change.wallet);
|
|
17
|
-
return { ok: true, wallet: change.wallet, created, notes: change.notes };
|
|
18
|
-
}
|
|
19
|
-
export function addCard(file, catalog, input, today) {
|
|
20
|
-
return attempt(async () => {
|
|
21
|
-
const { text, wallet } = await readWalletText(file);
|
|
22
|
-
const change = addToWallet(catalog, wallet, input, today);
|
|
23
|
-
if (!change.ok)
|
|
24
|
-
return change;
|
|
25
|
-
return commit(file, addCardText(text, { id: input.id, ...change.fields }), change, text === null);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
export function updateCard(file, catalog, input, today) {
|
|
29
|
-
return attempt(async () => {
|
|
30
|
-
const { text, wallet } = await readWalletText(file);
|
|
31
|
-
if (text === null || wallet === null)
|
|
32
|
-
return failed(noWallet(file));
|
|
33
|
-
const change = updateInWallet(catalog, wallet, input, today);
|
|
34
|
-
if (!change.ok)
|
|
35
|
-
return change;
|
|
36
|
-
return commit(file, updateCardText(text, input.id, change.fields), change, false);
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
export function removeCard(file, _catalog, id) {
|
|
40
|
-
return attempt(async () => {
|
|
41
|
-
const { text, wallet } = await readWalletText(file);
|
|
42
|
-
if (text === null || wallet === null)
|
|
43
|
-
return failed(noWallet(file));
|
|
44
|
-
const change = removeFromWallet(wallet, id);
|
|
45
|
-
if (!change.ok)
|
|
46
|
-
return change;
|
|
47
|
-
return commit(file, removeCardText(text, id), change, false);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
export function setMemberships(file, catalog, memberships) {
|
|
51
|
-
return attempt(async () => {
|
|
52
|
-
const { text, wallet } = await readWalletText(file);
|
|
53
|
-
if (text === null || wallet === null)
|
|
54
|
-
return failed(noWallet(file));
|
|
55
|
-
const change = withMemberships(catalog, wallet, memberships);
|
|
56
|
-
if (!change.ok)
|
|
57
|
-
return change;
|
|
58
|
-
return commit(file, setMembershipsText(text, change.wallet.memberships), change, false);
|
|
59
|
-
});
|
|
60
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
-
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
4
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
5
|
-
const lineStart = (text, at) => text.lastIndexOf('\n', at - 1) + 1;
|
|
6
|
-
function afterLine(text, at) {
|
|
7
|
-
const end = text.indexOf('\n', at);
|
|
8
|
-
return end === -1 ? text.length : end + 1;
|
|
9
|
-
}
|
|
10
|
-
// Inserts whole lines at a line boundary, adding the missing newline when the
|
|
11
|
-
// file's last line has none.
|
|
12
|
-
function insertLines(text, at, lines) {
|
|
13
|
-
const head = text.slice(0, at);
|
|
14
|
-
const gap = head.length > 0 && !head.endsWith('\n') ? '\n' : '';
|
|
15
|
-
return `${head}${gap}${lines}${text.slice(at)}`;
|
|
16
|
-
}
|
|
17
|
-
const column = (text, at) => at - lineStart(text, at);
|
|
18
|
-
function range(node) {
|
|
19
|
-
const r = node?.range;
|
|
20
|
-
if (!r)
|
|
21
|
-
throw new Error('wallet YAML node has no source position');
|
|
22
|
-
return r;
|
|
23
|
-
}
|
|
24
|
-
function flowUsage(booking) {
|
|
25
|
-
const period = /^\d+$/.test(booking.period)
|
|
26
|
-
? `"${booking.period}"`
|
|
27
|
-
: booking.period;
|
|
28
|
-
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
29
|
-
}
|
|
30
|
-
// Splices one line into the file's text instead of re-serializing the YAML,
|
|
31
|
-
// so comments, ordering and formatting elsewhere stay byte-for-byte the same.
|
|
32
|
-
function spliceBooking(text, booking) {
|
|
33
|
-
const doc = parseDocument(text);
|
|
34
|
-
const cards = doc.get('cards', true);
|
|
35
|
-
if (!isSeq(cards))
|
|
36
|
-
throw new Error('"cards" isn\'t a list');
|
|
37
|
-
const card = cards.items.find((item) => isMap(item) && item.get('id') === booking.cardId);
|
|
38
|
-
if (!isMap(card))
|
|
39
|
-
throw new Error(`no card '${booking.cardId}'`);
|
|
40
|
-
const value = flowUsage(booking);
|
|
41
|
-
const usage = card.get('cap_usage', true);
|
|
42
|
-
if (isMap(usage) && !usage.flow && usage.items.length > 0) {
|
|
43
|
-
const existing = usage.items.find((p) => p.key?.value === booking.capKey);
|
|
44
|
-
if (existing) {
|
|
45
|
-
const [start, end] = range(existing.value);
|
|
46
|
-
return text.slice(0, start) + value + text.slice(end);
|
|
47
|
-
}
|
|
48
|
-
const first = usage.items[0];
|
|
49
|
-
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
50
|
-
const at = afterLine(text, range(usage)[1] - 1);
|
|
51
|
-
return insertLines(text, at, `${indent}${booking.capKey}: ${value}\n`);
|
|
52
|
-
}
|
|
53
|
-
if (usage !== undefined) {
|
|
54
|
-
throw new Error(`card '${booking.cardId}' has a cap_usage that isn't a block map; edit it by hand`);
|
|
55
|
-
}
|
|
56
|
-
const firstKey = card.items[0]?.key;
|
|
57
|
-
const indent = ' '.repeat(column(text, range(firstKey)[0]));
|
|
58
|
-
const at = afterLine(text, range(card)[1] - 1);
|
|
59
|
-
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
60
|
-
}
|
|
61
|
-
function spliceBonusSpent(text, cardId, spent) {
|
|
62
|
-
const doc = parseDocument(text);
|
|
63
|
-
const cards = doc.get('cards', true);
|
|
64
|
-
if (!isSeq(cards))
|
|
65
|
-
throw new Error('"cards" isn\'t a list');
|
|
66
|
-
const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
|
|
67
|
-
if (!isMap(card))
|
|
68
|
-
throw new Error(`no card '${cardId}'`);
|
|
69
|
-
const bonus = card.get('bonus', true);
|
|
70
|
-
if (!isMap(bonus))
|
|
71
|
-
throw new Error(`card '${cardId}' has no bonus`);
|
|
72
|
-
const value = String(spent);
|
|
73
|
-
const existing = bonus.items.find((p) => p.key?.value === 'spent');
|
|
74
|
-
if (existing) {
|
|
75
|
-
const [start, end] = range(existing.value);
|
|
76
|
-
return text.slice(0, start) + value + text.slice(end);
|
|
77
|
-
}
|
|
78
|
-
if (bonus.flow) {
|
|
79
|
-
const close = text.lastIndexOf('}', range(bonus)[1]);
|
|
80
|
-
const before = text.slice(0, close).trimEnd();
|
|
81
|
-
return `${before}, spent: ${value} ${text.slice(close)}`;
|
|
82
|
-
}
|
|
83
|
-
const first = bonus.items[0];
|
|
84
|
-
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
85
|
-
const at = afterLine(text, range(bonus)[1] - 1);
|
|
86
|
-
return insertLines(text, at, `${indent}spent: ${value}\n`);
|
|
87
|
-
}
|
|
88
|
-
// Sets how much has been spent toward a card's welcome bonus.
|
|
89
|
-
export async function writeBonusSpent(file, cardId, spent) {
|
|
90
|
-
const before = await readFile(file, 'utf8');
|
|
91
|
-
const after = spliceBonusSpent(before, cardId, spent);
|
|
92
|
-
const oldWallet = WalletSchema.parse(parseDocument(before).toJS());
|
|
93
|
-
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
94
|
-
const expected = {
|
|
95
|
-
...oldWallet,
|
|
96
|
-
cards: oldWallet.cards.map((c) => c.id === cardId && c.bonus ? { ...c, bonus: { ...c.bonus, spent } } : c),
|
|
97
|
-
};
|
|
98
|
-
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
99
|
-
throw new Error(`couldn't update ${file} safely; set the bonus's spent to ${String(spent)} by hand`);
|
|
100
|
-
}
|
|
101
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
102
|
-
await writeFile(tmp, after);
|
|
103
|
-
await rename(tmp, file);
|
|
104
|
-
}
|
|
105
|
-
const queues = new Map();
|
|
106
|
-
// Runs `task` after every earlier task for the same file has settled, so a
|
|
107
|
-
// read-modify-write of the wallet never interleaves with another.
|
|
108
|
-
export function withWalletLock(file, task) {
|
|
109
|
-
const previous = queues.get(file) ?? Promise.resolve();
|
|
110
|
-
const run = previous.then(task, task);
|
|
111
|
-
queues.set(file, run.catch(() => undefined));
|
|
112
|
-
return run;
|
|
113
|
-
}
|
|
114
|
-
export async function writeCapBooking(file, booking) {
|
|
115
|
-
const before = await readFile(file, 'utf8');
|
|
116
|
-
const after = spliceBooking(before, booking);
|
|
117
|
-
const oldWallet = WalletSchema.parse(parseDocument(before).toJS());
|
|
118
|
-
const parsed = WalletSchema.safeParse(parseDocument(after).toJS());
|
|
119
|
-
const expected = {
|
|
120
|
-
...oldWallet,
|
|
121
|
-
cards: oldWallet.cards.map((c) => c.id === booking.cardId
|
|
122
|
-
? {
|
|
123
|
-
...c,
|
|
124
|
-
cap_usage: {
|
|
125
|
-
...c.cap_usage,
|
|
126
|
-
[booking.capKey]: {
|
|
127
|
-
period: booking.period,
|
|
128
|
-
spent: booking.after,
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
}
|
|
132
|
-
: c),
|
|
133
|
-
};
|
|
134
|
-
if (!parsed.success || !isDeepStrictEqual(parsed.data, expected)) {
|
|
135
|
-
throw new Error(`couldn't update ${file} safely; add the cap usage by hand: ${booking.capKey}: ${flowUsage(booking)}`);
|
|
136
|
-
}
|
|
137
|
-
const tmp = `${file}.${String(process.pid)}.tmp`;
|
|
138
|
-
await writeFile(tmp, after);
|
|
139
|
-
await rename(tmp, file);
|
|
140
|
-
}
|
|
File without changes
|