earnrate 0.1.4 → 0.2.0
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 +40 -7
- package/data/CHANGELOG.md +8 -0
- package/data/VERSION +1 -1
- package/data/categories.yaml +22 -7
- package/dist/cli/args.js +9 -0
- package/dist/cli/cheatsheet.js +32 -28
- package/dist/cli/explore.js +23 -140
- package/dist/cli/format.js +7 -5
- package/dist/cli/offers.js +10 -53
- package/dist/cli/run.js +80 -14
- package/dist/cli/wallet.js +40 -0
- package/dist/data/file-store.js +56 -0
- package/dist/data/load.js +9 -1
- package/dist/data/store.js +108 -0
- package/dist/data/wallet-edit.js +25 -2
- package/dist/engine/cheat-sheet.js +57 -2
- package/dist/engine/explore.js +7 -6
- package/dist/engine/recommend.js +3 -0
- package/dist/engine/record.js +35 -0
- package/dist/engine/simulate.js +22 -1
- package/dist/engine/transient.js +21 -0
- package/dist/engine/wallet-change.js +28 -1
- package/dist/format/cheat-sheet.js +19 -0
- package/dist/format/explore.js +124 -0
- package/dist/format/offers.js +42 -0
- package/dist/format/wallet-yaml.js +45 -0
- package/dist/mcp/schemas.js +7 -1
- package/dist/mcp/serve.js +3 -1
- package/dist/mcp/server.js +304 -117
- package/dist/schema/catalog.js +1 -0
- package/package.json +7 -2
package/dist/cli/offers.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { CLI_NAME } from "../brand.js";
|
|
3
|
-
import { walletProblems } from "../data/integrity.js";
|
|
4
3
|
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
-
import { compareOffers
|
|
4
|
+
import { compareOffers } from "../engine/offers.js";
|
|
6
5
|
import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
|
|
7
|
-
import {
|
|
6
|
+
import { formatOffers } from "../format/offers.js";
|
|
7
|
+
import { cardsToUse, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
8
8
|
export const OFFERS_HELP = `Usage: ${CLI_NAME} offers [--cash | --points] [--no-fee] [--business] [options]
|
|
9
9
|
|
|
10
10
|
Ranks the welcome offers on cards you don't have by first-year value: the
|
|
@@ -21,48 +21,6 @@ Options:
|
|
|
21
21
|
--json Print the full comparison as JSON.
|
|
22
22
|
-h, --help Show this help.
|
|
23
23
|
`;
|
|
24
|
-
const TOP = 10;
|
|
25
|
-
const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
26
|
-
function reward(o, currencyName) {
|
|
27
|
-
if (o.dollars !== null)
|
|
28
|
-
return usd(o.dollars);
|
|
29
|
-
return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
|
|
30
|
-
}
|
|
31
|
-
function feeText(o) {
|
|
32
|
-
if (o.annualFee === null)
|
|
33
|
-
return 'annual fee not on file';
|
|
34
|
-
if (o.annualFee === 0)
|
|
35
|
-
return 'no annual fee';
|
|
36
|
-
if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
|
|
37
|
-
return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
|
|
38
|
-
return `${usd(o.annualFee)} annual fee`;
|
|
39
|
-
}
|
|
40
|
-
export function formatOffers(c, currencyName, all) {
|
|
41
|
-
if (c.offers.length === 0) {
|
|
42
|
-
return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
|
|
43
|
-
}
|
|
44
|
-
const shown = all ? c.offers : c.offers.slice(0, TOP);
|
|
45
|
-
const width = Math.max(...shown.map((o) => o.cardName.length));
|
|
46
|
-
const lines = [];
|
|
47
|
-
shown.forEach((o, i) => {
|
|
48
|
-
const weeks = (o.months * 52) / 12;
|
|
49
|
-
const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
|
|
50
|
-
const spend = o.spend > 0
|
|
51
|
-
? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
|
|
52
|
-
: 'with no spending required';
|
|
53
|
-
lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${usd(Math.round(o.firstYearValue))} the first year`, ` ${reward(o, currencyName(o.currency))} ${spend}. ${feeText(o)[0]?.toUpperCase() ?? ''}${feeText(o).slice(1)}.${o.held ? ' In your wallet.' : ''}`);
|
|
54
|
-
if (o.validTo)
|
|
55
|
-
lines.push(` Offer ends ${o.validTo}.`);
|
|
56
|
-
if (o.note)
|
|
57
|
-
lines.push(` ${o.note}`);
|
|
58
|
-
});
|
|
59
|
-
if (c.offers.length > shown.length)
|
|
60
|
-
lines.push(` + ${String(c.offers.length - shown.length)} more (--all lists them)`);
|
|
61
|
-
if (c.withoutOffer.length > 0)
|
|
62
|
-
lines.push('', `${String(c.withoutOffer.length)} matching card(s) have no published offer on file.`);
|
|
63
|
-
lines.push('', ...c.notes);
|
|
64
|
-
return `${lines.join('\n')}\n`;
|
|
65
|
-
}
|
|
66
24
|
export async function runOffers(argv, io) {
|
|
67
25
|
let parsed;
|
|
68
26
|
try {
|
|
@@ -98,16 +56,14 @@ export async function runOffers(argv, io) {
|
|
|
98
56
|
io.stderr(`Unknown valuation "${values.valuation}". Use flat or points-heavy.\n`);
|
|
99
57
|
return USAGE_ERROR;
|
|
100
58
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
59
|
+
const found = await cardsToUse(walletPath(io.env), io, {
|
|
60
|
+
cards: null,
|
|
61
|
+
members: [],
|
|
62
|
+
});
|
|
63
|
+
if (found === null)
|
|
104
64
|
return RUNTIME_ERROR;
|
|
105
65
|
const catalog = await loadCatalog();
|
|
106
|
-
const
|
|
107
|
-
if (problems.length > 0) {
|
|
108
|
-
io.stderr(`Your wallet at ${displayPath(walletFile)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
109
|
-
return RUNTIME_ERROR;
|
|
110
|
-
}
|
|
66
|
+
const loaded = found === 'none' ? { valuations: {}, memberships: [], cards: [] } : found;
|
|
111
67
|
const wallet = values.valuation && isValuationProfile(values.valuation)
|
|
112
68
|
? withValuationProfile(loaded, values.valuation)
|
|
113
69
|
: loaded;
|
|
@@ -125,3 +81,4 @@ export async function runOffers(argv, io) {
|
|
|
125
81
|
io.stdout(formatOffers(comparison, currencyName, values.all ?? false));
|
|
126
82
|
return 0;
|
|
127
83
|
}
|
|
84
|
+
export { formatOffers };
|
package/dist/cli/run.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
1
2
|
import { homedir } from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { CLI_NAME, CONFIG_DIR, LLM_ENV_VAR, LLM_MAX_CALLS_ENV_VAR, LLM_MODEL_ENV_VAR, MERCHANT_CACHE_ENV_VAR, WALLET_ENV_VAR, } from "../brand.js";
|
|
5
6
|
import { walletProblems } from "../data/integrity.js";
|
|
6
7
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
7
|
-
import { withValuationProfile } from "../engine/profiles.js";
|
|
8
|
+
import { VALUATION_PROFILES, withValuationProfile } from "../engine/profiles.js";
|
|
9
|
+
import { ALL_PERSONAL_CARDS, categoryResolution, exploreCards, findCategory, } from "../engine/explore.js";
|
|
8
10
|
import { recommend } from "../engine/recommend.js";
|
|
11
|
+
import { walletFromCards } from "../engine/transient.js";
|
|
9
12
|
import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
|
|
10
13
|
import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
|
|
11
14
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
@@ -13,14 +16,16 @@ import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
|
|
|
13
16
|
import { packageVersion } from "../version.js";
|
|
14
17
|
import { parseCliArgs } from "./args.js";
|
|
15
18
|
import { runCheatSheet } from "./cheatsheet.js";
|
|
16
|
-
import { runBest, runSimulate } from "./explore.js";
|
|
19
|
+
import { formatExploration, runBest, runSimulate } from "./explore.js";
|
|
17
20
|
import { runImport } from "./import.js";
|
|
18
21
|
import { runOffers } from "./offers.js";
|
|
19
22
|
import { runWalletCommand, WALLET_HELP } from "./wallet.js";
|
|
20
23
|
import { formatRecommendation, plain } from "./format.js";
|
|
21
24
|
export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
|
|
22
25
|
|
|
23
|
-
Picks the card
|
|
26
|
+
Picks the card that earns the most at a merchant, and says why: from your
|
|
27
|
+
wallet if you've saved one, from cards you name with --cards, or else from
|
|
28
|
+
every card earnrate knows.
|
|
24
29
|
|
|
25
30
|
Examples:
|
|
26
31
|
${CLI_NAME} whole foods 80
|
|
@@ -53,6 +58,9 @@ Options:
|
|
|
53
58
|
--json Print the full recommendation as JSON.
|
|
54
59
|
--date <YYYY-MM-DD> Purchase date. Default: today.
|
|
55
60
|
--country <code> Where you're paying. Default: US.
|
|
61
|
+
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
62
|
+
separated: --cards amex-gold,chase-prime-visa.
|
|
63
|
+
--member <name> A membership you have, with --cards or no wallet.
|
|
56
64
|
--category <id> For a merchant earnrate doesn't know: its category,
|
|
57
65
|
e.g. grocery.supermarket. Ignored for known merchants.
|
|
58
66
|
--llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
|
|
@@ -75,6 +83,38 @@ export function displayPath(file) {
|
|
|
75
83
|
const home = homedir();
|
|
76
84
|
return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
|
|
77
85
|
}
|
|
86
|
+
// The cards to answer for: those named with --cards, else the wallet file.
|
|
87
|
+
// 'none' means no wallet and no --cards, which answers from every card.
|
|
88
|
+
export async function cardsToUse(file, io, named) {
|
|
89
|
+
const catalog = await loadCatalog();
|
|
90
|
+
if (named.cards !== null) {
|
|
91
|
+
const wallet = walletFromCards(catalog, {
|
|
92
|
+
cards: named.cards.map((id) => ({ id })),
|
|
93
|
+
memberships: named.members,
|
|
94
|
+
}, io.today);
|
|
95
|
+
if (typeof wallet === 'string') {
|
|
96
|
+
io.stderr(`${wallet}\n`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return wallet;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await access(file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 'none';
|
|
106
|
+
}
|
|
107
|
+
const loaded = await readWallet(file, io);
|
|
108
|
+
if (!loaded)
|
|
109
|
+
return null;
|
|
110
|
+
const problems = walletProblems(loaded, catalog);
|
|
111
|
+
if (problems.length > 0) {
|
|
112
|
+
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return loaded;
|
|
116
|
+
}
|
|
117
|
+
export const NO_WALLET_HINT = `These are every card earnrate knows. For the best of your own cards, name them with --cards amex-gold,chase-prime-visa, or save them: ${CLI_NAME} add <card-id> (${CLI_NAME} cards finds ids).`;
|
|
78
118
|
export async function readWallet(file, io) {
|
|
79
119
|
try {
|
|
80
120
|
return await loadWallet(file);
|
|
@@ -120,18 +160,10 @@ export async function run(argv, io) {
|
|
|
120
160
|
const { options } = args;
|
|
121
161
|
const llmOn = options.llm ?? llmEnabled(io.env);
|
|
122
162
|
const file = walletPath(io.env);
|
|
123
|
-
const loaded = await
|
|
124
|
-
if (
|
|
163
|
+
const loaded = await cardsToUse(file, io, options);
|
|
164
|
+
if (loaded === null)
|
|
125
165
|
return RUNTIME_ERROR;
|
|
126
166
|
const catalog = await loadCatalog();
|
|
127
|
-
const problems = walletProblems(loaded, catalog);
|
|
128
|
-
if (problems.length > 0) {
|
|
129
|
-
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
130
|
-
return RUNTIME_ERROR;
|
|
131
|
-
}
|
|
132
|
-
const wallet = options.valuation
|
|
133
|
-
? withValuationProfile(loaded, options.valuation)
|
|
134
|
-
: loaded;
|
|
135
167
|
const purchase = {
|
|
136
168
|
merchant: options.merchant,
|
|
137
169
|
amount: options.amount,
|
|
@@ -144,7 +176,14 @@ export async function run(argv, io) {
|
|
|
144
176
|
io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
|
|
145
177
|
return USAGE_ERROR;
|
|
146
178
|
}
|
|
147
|
-
|
|
179
|
+
// Plain category words ("restaurants", "groceries") work like stores.
|
|
180
|
+
const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
|
|
181
|
+
const named = resolvedName.source === 'none' && options.category === null
|
|
182
|
+
? findCategory(catalog, purchase.merchant)
|
|
183
|
+
: null;
|
|
184
|
+
let merchant = named === null
|
|
185
|
+
? withCallerCategory(resolvedName, options.category)
|
|
186
|
+
: categoryResolution(catalog, named, purchase.channel);
|
|
148
187
|
const notices = [];
|
|
149
188
|
if (llmOn && merchant.source === 'none') {
|
|
150
189
|
const opened = await openLlmSession({
|
|
@@ -161,6 +200,33 @@ export async function run(argv, io) {
|
|
|
161
200
|
merchant = resolved.merchant;
|
|
162
201
|
notices.push(...resolved.notices);
|
|
163
202
|
}
|
|
203
|
+
if (loaded === 'none') {
|
|
204
|
+
const explored = exploreCards(catalog, {
|
|
205
|
+
merchant: purchase.merchant,
|
|
206
|
+
category: null,
|
|
207
|
+
amount: purchase.amount,
|
|
208
|
+
channel: purchase.channel,
|
|
209
|
+
date: purchase.date,
|
|
210
|
+
}, {
|
|
211
|
+
filters: ALL_PERSONAL_CARDS,
|
|
212
|
+
memberships: options.members,
|
|
213
|
+
valuations: options.valuation
|
|
214
|
+
? VALUATION_PROFILES[options.valuation]
|
|
215
|
+
: {},
|
|
216
|
+
}, merchant);
|
|
217
|
+
if (options.json) {
|
|
218
|
+
io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
io.stdout(`${formatExploration(explored, options.all)}\n${NO_WALLET_HINT}\n`);
|
|
222
|
+
}
|
|
223
|
+
for (const n of notices)
|
|
224
|
+
io.stderr(`${n}\n`);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
const wallet = options.valuation
|
|
228
|
+
? withValuationProfile(loaded, options.valuation)
|
|
229
|
+
: loaded;
|
|
164
230
|
const rec = recommend(catalog, wallet, purchase, merchant);
|
|
165
231
|
if (options.json) {
|
|
166
232
|
io.stdout(`${JSON.stringify(rec, null, 2)}\n`);
|
package/dist/cli/wallet.js
CHANGED
|
@@ -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
|
},
|
|
@@ -186,6 +198,34 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
186
198
|
return usage(picks);
|
|
187
199
|
input.picks = picks;
|
|
188
200
|
}
|
|
201
|
+
const dollarsFlag = (flag, text) => {
|
|
202
|
+
if (text === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
const n = Number(text.replace(/[$,]/g, ''));
|
|
205
|
+
return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
|
|
206
|
+
};
|
|
207
|
+
if (values['no-bonus']) {
|
|
208
|
+
input.bonus = null;
|
|
209
|
+
}
|
|
210
|
+
else if (values['bonus-opened'] !== undefined) {
|
|
211
|
+
const spent = dollarsFlag('bonus-spent', values['bonus-spent']);
|
|
212
|
+
const spend = dollarsFlag('bonus-spend', values['bonus-spend']);
|
|
213
|
+
if (typeof spent === 'string')
|
|
214
|
+
return usage(spent);
|
|
215
|
+
if (typeof spend === 'string')
|
|
216
|
+
return usage(spend);
|
|
217
|
+
input.bonus = {
|
|
218
|
+
opened: values['bonus-opened'],
|
|
219
|
+
...(spent === undefined ? {} : { spent }),
|
|
220
|
+
...(spend === undefined ? {} : { spend }),
|
|
221
|
+
...(values['bonus-by'] === undefined ? {} : { by: values['bonus-by'] }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
else if (values['bonus-spent'] !== undefined ||
|
|
225
|
+
values['bonus-spend'] !== undefined ||
|
|
226
|
+
values['bonus-by'] !== undefined) {
|
|
227
|
+
return usage('Give --bonus-opened with the other --bonus options.');
|
|
228
|
+
}
|
|
189
229
|
if (command === 'add') {
|
|
190
230
|
const result = await addCard(file, catalog, input, io.today);
|
|
191
231
|
return report(result, `Added ${name} to ${where}${result.ok && result.created ? ' (a new wallet)' : ''}.`, id, io);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { bookPurchase } from "../engine/record.js";
|
|
2
|
+
import { loadWallet } from "./load.js";
|
|
3
|
+
import { addCard, removeCard, setMemberships, updateCard, } from "./wallet-setup.js";
|
|
4
|
+
import { withWalletLock, writeBonusSpent, writeCapBooking, } from "./wallet-write.js";
|
|
5
|
+
// The wallet as a YAML file (the CLI, the local MCP server, the .mcpb and the
|
|
6
|
+
// local plugin). Edits splice lines so the user's comments survive.
|
|
7
|
+
export class FileWalletStore {
|
|
8
|
+
where;
|
|
9
|
+
catalog;
|
|
10
|
+
constructor(catalog, file) {
|
|
11
|
+
this.catalog = catalog;
|
|
12
|
+
this.where = file;
|
|
13
|
+
}
|
|
14
|
+
async load() {
|
|
15
|
+
try {
|
|
16
|
+
return await loadWallet(this.where);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e instanceof Error && 'code' in e && e.code === 'ENOENT')
|
|
20
|
+
return null;
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
addCard(input, today) {
|
|
25
|
+
return withWalletLock(this.where, () => addCard(this.where, this.catalog, input, today));
|
|
26
|
+
}
|
|
27
|
+
updateCard(input, today) {
|
|
28
|
+
return withWalletLock(this.where, () => updateCard(this.where, this.catalog, input, today));
|
|
29
|
+
}
|
|
30
|
+
removeCard(cardId) {
|
|
31
|
+
return withWalletLock(this.where, () => removeCard(this.where, this.catalog, cardId));
|
|
32
|
+
}
|
|
33
|
+
setMemberships(memberships) {
|
|
34
|
+
return withWalletLock(this.where, () => setMemberships(this.where, this.catalog, memberships));
|
|
35
|
+
}
|
|
36
|
+
record(purchase, merchant) {
|
|
37
|
+
return withWalletLock(this.where, async () => {
|
|
38
|
+
const wallet = await this.load();
|
|
39
|
+
if (!wallet)
|
|
40
|
+
return { ok: false, error: "There's no saved wallet to record it in." };
|
|
41
|
+
if (!wallet.cards.some((c) => c.id === purchase.cardId))
|
|
42
|
+
return { ok: false, error: `${purchase.cardId} isn't in the wallet.` };
|
|
43
|
+
const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
|
|
44
|
+
try {
|
|
45
|
+
if (booked.cap)
|
|
46
|
+
await writeCapBooking(this.where, booked.cap);
|
|
47
|
+
if (booked.bonus)
|
|
48
|
+
await writeBonusSpent(this.where, purchase.cardId, booked.bonus.after);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, ...booked };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/data/load.js
CHANGED
|
@@ -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,108 @@
|
|
|
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
|
+
// A store with nothing behind it, for tests and for a session that starts
|
|
80
|
+
// empty and is thrown away.
|
|
81
|
+
export function memoryBackend(initial = null) {
|
|
82
|
+
let text = initial;
|
|
83
|
+
return {
|
|
84
|
+
read: () => Promise.resolve(text),
|
|
85
|
+
write: (next) => {
|
|
86
|
+
text = next;
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
},
|
|
89
|
+
remove: () => {
|
|
90
|
+
text = null;
|
|
91
|
+
return Promise.resolve();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
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
|
@@ -38,6 +38,18 @@ function fieldLines(field, f, indent) {
|
|
|
38
38
|
return f.activated === undefined
|
|
39
39
|
? ''
|
|
40
40
|
: `${indent}activated: ${flowList(f.activated)}\n`;
|
|
41
|
+
case 'bonus': {
|
|
42
|
+
if (!f.bonus)
|
|
43
|
+
return '';
|
|
44
|
+
const b = f.bonus;
|
|
45
|
+
return [
|
|
46
|
+
`${indent}bonus:\n`,
|
|
47
|
+
`${indent} opened: ${scalar(b.opened)}\n`,
|
|
48
|
+
`${indent} spent: ${String(b.spent)}\n`,
|
|
49
|
+
b.spend === undefined ? '' : `${indent} spend: ${String(b.spend)}\n`,
|
|
50
|
+
b.by === undefined ? '' : `${indent} by: ${scalar(b.by)}\n`,
|
|
51
|
+
].join('');
|
|
52
|
+
}
|
|
41
53
|
case 'chosen': {
|
|
42
54
|
if (f.chosen === undefined)
|
|
43
55
|
return '';
|
|
@@ -48,7 +60,13 @@ function fieldLines(field, f, indent) {
|
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
}
|
|
51
|
-
const FIELDS = [
|
|
63
|
+
const FIELDS = [
|
|
64
|
+
'checkout_label',
|
|
65
|
+
'anniversary',
|
|
66
|
+
'chosen',
|
|
67
|
+
'activated',
|
|
68
|
+
'bonus',
|
|
69
|
+
];
|
|
52
70
|
function cardsSeq(text) {
|
|
53
71
|
const doc = parseDocument(text);
|
|
54
72
|
if (doc.errors.length > 0)
|
|
@@ -105,7 +123,12 @@ export function updateCardText(text, cardId, fields) {
|
|
|
105
123
|
const indent = ' '.repeat(column(out, range(card)[0]));
|
|
106
124
|
const lines = fieldLines(field, fields, indent);
|
|
107
125
|
const pair = card.items.find((p) => keyOf(p) === field);
|
|
108
|
-
if (pair) {
|
|
126
|
+
if (pair && lines === '') {
|
|
127
|
+
const start = lineStart(out, range(pair.key)[0]);
|
|
128
|
+
const end = afterLine(out, range(pair.value)[1] - 1);
|
|
129
|
+
out = out.slice(0, start) + out.slice(end);
|
|
130
|
+
}
|
|
131
|
+
else if (pair) {
|
|
109
132
|
const start = range(pair.key)[0];
|
|
110
133
|
const end = afterLine(out, range(pair.value)[1] - 1);
|
|
111
134
|
out = out.slice(0, start) + lines.trimStart() + out.slice(end);
|
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
@@ -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/explore.js
CHANGED
|
@@ -92,7 +92,7 @@ export function categoryResolution(catalog, category, channel) {
|
|
|
92
92
|
ambiguity: null,
|
|
93
93
|
channel,
|
|
94
94
|
confidence: 'high',
|
|
95
|
-
source: '
|
|
95
|
+
source: 'category',
|
|
96
96
|
bookedVia: [],
|
|
97
97
|
inference: null,
|
|
98
98
|
};
|
|
@@ -198,10 +198,11 @@ function compareExplored(a, b) {
|
|
|
198
198
|
}
|
|
199
199
|
// Ranks every card in the catalog that passes the filters for one purchase,
|
|
200
200
|
// each set up at its best: the pick that covers it, activated, cap unused.
|
|
201
|
-
export function exploreCards(catalog, query, options) {
|
|
202
|
-
const merchant =
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
export function exploreCards(catalog, query, options, resolved) {
|
|
202
|
+
const merchant = resolved ??
|
|
203
|
+
(query.merchant !== null
|
|
204
|
+
? resolveMerchant(catalog, query.merchant, query.channel)
|
|
205
|
+
: categoryResolution(catalog, query.category ?? 'other', query.channel));
|
|
205
206
|
const purchase = {
|
|
206
207
|
merchant: merchant.input,
|
|
207
208
|
amount: query.amount,
|
|
@@ -257,7 +258,7 @@ export function exploreCards(catalog, query, options) {
|
|
|
257
258
|
}
|
|
258
259
|
function exploreAssumptions(catalog, merchant, ranked, options) {
|
|
259
260
|
const assumptions = [];
|
|
260
|
-
if (merchant.source === '
|
|
261
|
+
if (merchant.source === 'category') {
|
|
261
262
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
262
263
|
}
|
|
263
264
|
else if (merchant.source !== 'none') {
|
package/dist/engine/recommend.js
CHANGED
|
@@ -258,6 +258,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
258
258
|
if (merchant.source === 'none') {
|
|
259
259
|
assumptions.push(`Treats "${merchant.input}" as an everyday purchase with no bonus category.`);
|
|
260
260
|
}
|
|
261
|
+
else if (merchant.source === 'category') {
|
|
262
|
+
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
263
|
+
}
|
|
261
264
|
else if (merchant.category === 'other') {
|
|
262
265
|
assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
|
|
263
266
|
}
|