earnrate 0.2.5 → 0.2.7
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 +25 -18
- package/data/CHANGELOG.md +56 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +14 -6
- package/data/cards/amex-blue-cash-everyday.yaml +12 -3
- package/data/cards/amex-blue-cash-preferred.yaml +17 -3
- package/data/cards/amex-gold.yaml +19 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +10 -2
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +14 -3
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +4 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -5
- package/data/cards/bilt-obsidian.yaml +4 -3
- package/data/cards/capital-one-kohls-card.yaml +11 -2
- package/data/cards/capital-one-kohls-rewards-visa.yaml +8 -2
- package/data/cards/capital-one-savor.yaml +32 -10
- package/data/cards/capital-one-venture-x.yaml +19 -4
- package/data/cards/capital-one-venture.yaml +19 -5
- package/data/cards/chase-aeroplan.yaml +15 -3
- package/data/cards/chase-freedom-flex.yaml +49 -10
- package/data/cards/chase-freedom-unlimited.yaml +6 -2
- package/data/cards/chase-ihg-one-rewards-business.yaml +18 -2
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +14 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -2
- package/data/cards/chase-ihg-one-rewards.yaml +8 -2
- package/data/cards/chase-ink-business-cash.yaml +7 -3
- package/data/cards/chase-ink-business-preferred.yaml +9 -2
- package/data/cards/chase-prime-visa.yaml +24 -5
- package/data/cards/chase-sapphire-preferred.yaml +59 -6
- package/data/cards/chase-sapphire-reserve.yaml +12 -2
- package/data/cards/chase-southwest-performance-business.yaml +22 -4
- package/data/cards/chase-southwest-plus.yaml +13 -3
- package/data/cards/chase-southwest-premier-business.yaml +22 -5
- package/data/cards/chase-southwest-premier.yaml +13 -3
- package/data/cards/chase-southwest-priority.yaml +18 -2
- package/data/cards/chase-united-business.yaml +5 -2
- package/data/cards/chase-united-club-business.yaml +2 -2
- package/data/cards/chase-united-club.yaml +16 -3
- package/data/cards/chase-united-explorer.yaml +5 -2
- package/data/cards/chase-united-gateway.yaml +2 -2
- package/data/cards/chase-united-quest.yaml +12 -3
- package/data/cards/citi-aadvantage-business.yaml +10 -4
- package/data/cards/citi-aadvantage-executive.yaml +8 -8
- package/data/cards/citi-aadvantage-mileup.yaml +7 -4
- package/data/cards/citi-aadvantage-platinum-select.yaml +1 -1
- package/data/cards/citi-costco-anywhere-visa-business.yaml +5 -2
- package/data/cards/citi-costco-anywhere-visa.yaml +6 -3
- package/data/cards/citi-custom-cash.yaml +43 -12
- package/data/cards/citi-double-cash.yaml +9 -3
- package/data/cards/citi-my-best-buy-visa.yaml +6 -3
- package/data/cards/citi-strata-elite.yaml +12 -2
- package/data/cards/citi-strata-premier.yaml +24 -2
- package/data/cards/sofi-unlimited-2.yaml +17 -3
- package/data/cards/td-bank-nordstrom-credit-card.yaml +12 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +19 -3
- package/data/cards/td-bank-target-circle-card.yaml +13 -4
- package/data/cards/usbank-cash-plus.yaml +29 -7
- package/data/cards/wells-fargo-active-cash.yaml +9 -2
- package/data/cards/wells-fargo-autograph.yaml +31 -7
- package/data/issuers.yaml +32 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/brand.js +1 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +10 -5
- package/dist/cli/explore.js +6 -13
- package/dist/cli/import.js +2 -2
- package/dist/cli/missed.js +65 -0
- package/dist/cli/offers.js +17 -7
- package/dist/cli/run.js +29 -17
- package/dist/cli/todo.js +62 -0
- package/dist/cli/wallet.js +9 -6
- package/dist/data/file-store.js +6 -7
- package/dist/data/integrity.js +4 -0
- package/dist/data/load.js +4 -1
- package/dist/data/store.js +6 -22
- package/dist/data/wallet-edit.js +38 -13
- package/dist/engine/cheat-sheet.js +14 -6
- package/dist/engine/checklist.js +152 -0
- package/dist/engine/compare.js +2 -0
- package/dist/engine/evaluate.js +16 -11
- package/dist/engine/explore.js +6 -38
- package/dist/engine/memberships.js +1 -1
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +18 -0
- package/dist/engine/recommend.js +21 -20
- package/dist/engine/rule-text.js +47 -0
- package/dist/engine/setup.js +14 -24
- package/dist/engine/simulate.js +7 -6
- package/dist/engine/text.js +13 -1
- package/dist/engine/wallet-change.js +10 -12
- package/dist/engine/wallet-summary.js +80 -65
- package/dist/format/card-text.js +7 -20
- package/dist/format/explore.js +2 -2
- package/dist/format/offers.js +2 -2
- package/dist/format/recommendation.js +2 -2
- package/dist/format/wallet.js +7 -1
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +38 -13
- package/dist/mcp/inputs.js +1 -1
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/serve.js +4 -0
- package/dist/mcp/server.js +8 -2
- package/dist/mcp/tools/account.js +44 -16
- package/dist/mcp/tools/answers.js +58 -19
- package/dist/mcp/tools/wallet.js +64 -25
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/missed.js +78 -0
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +4 -1
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/cli/run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
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";
|
|
6
6
|
import { walletProblems } from "../data/integrity.js";
|
|
7
7
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
8
|
-
import { ALL_PERSONAL_CARDS,
|
|
8
|
+
import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
|
|
9
9
|
import { profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
|
|
10
10
|
import { recommend } from "../engine/recommend.js";
|
|
11
11
|
import { walletFromCards } from "../engine/transient.js";
|
|
@@ -14,10 +14,13 @@ import { formatRecommendation, plain, } from "../format/recommendation.js";
|
|
|
14
14
|
import { DEFAULT_LLM_MODEL } from "../merchants/anthropic.js";
|
|
15
15
|
import { DEFAULT_MAX_CALLS } from "../merchants/fallback.js";
|
|
16
16
|
import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
|
|
17
|
-
import {
|
|
17
|
+
import { missedPath, missLogAt } from "../merchants/missed.js";
|
|
18
|
+
import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
|
|
18
19
|
import { packageVersion } from "../version.js";
|
|
19
20
|
import { membershipProblem, parseCliArgs } from "./args.js";
|
|
20
21
|
import { runCheatSheet } from "./cheatsheet.js";
|
|
22
|
+
import { runMissed } from "./missed.js";
|
|
23
|
+
import { runTodo } from "./todo.js";
|
|
21
24
|
import { runBest, runSimulate } from "./explore.js";
|
|
22
25
|
import { runImport } from "./import.js";
|
|
23
26
|
import { runOffers } from "./offers.js";
|
|
@@ -47,6 +50,8 @@ Save your cards (optional):
|
|
|
47
50
|
|
|
48
51
|
Other commands:
|
|
49
52
|
${CLI_NAME} cheatsheet The best card for each category this quarter.
|
|
53
|
+
${CLI_NAME} todo [--ics file] What your cards need this quarter, with dates.
|
|
54
|
+
${CLI_NAME} missed Stores earnrate didn't recognize, kept on this computer.
|
|
50
55
|
${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
|
|
51
56
|
${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
|
|
52
57
|
${CLI_NAME} mcp Run the MCP server over stdio, for AI assistants.
|
|
@@ -63,7 +68,7 @@ Options:
|
|
|
63
68
|
--country <code> Where you're paying. Default: US.
|
|
64
69
|
--cards <ids> Answer for these cards instead of your wallet, comma
|
|
65
70
|
separated: --cards amex-gold,chase-prime-visa.
|
|
66
|
-
--member <name> A membership you have,
|
|
71
|
+
--member <name> A membership you have, in place of the saved ones.
|
|
67
72
|
--category <id> For a merchant earnrate doesn't know: its category,
|
|
68
73
|
e.g. grocery.supermarket. Ignored for known merchants.
|
|
69
74
|
--llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
|
|
@@ -87,7 +92,9 @@ export function displayPath(file) {
|
|
|
87
92
|
return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
|
|
88
93
|
}
|
|
89
94
|
// The cards to answer for: those named with --cards, else the wallet file.
|
|
90
|
-
// 'none' means no
|
|
95
|
+
// 'none' means no cards saved or named, which answers from every card.
|
|
96
|
+
// Named cards use the saved memberships (unless --member names some) and the
|
|
97
|
+
// saved point values, as the MCP tools do.
|
|
91
98
|
export async function cardsToUse(catalog, file, io, named) {
|
|
92
99
|
const problem = membershipProblem(catalog, named.members);
|
|
93
100
|
if (problem !== null) {
|
|
@@ -95,15 +102,16 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
95
102
|
return null;
|
|
96
103
|
}
|
|
97
104
|
if (named.cards !== null) {
|
|
105
|
+
const saved = await savedSettings(io);
|
|
98
106
|
const wallet = walletFromCards(catalog, {
|
|
99
107
|
cards: named.cards.map((id) => ({ id })),
|
|
100
|
-
memberships: named.members,
|
|
108
|
+
memberships: named.members.length > 0 ? named.members : saved.memberships,
|
|
101
109
|
}, io.today);
|
|
102
110
|
if (typeof wallet === 'string') {
|
|
103
111
|
io.stderr(`${wallet}\n`);
|
|
104
112
|
return null;
|
|
105
113
|
}
|
|
106
|
-
return wallet;
|
|
114
|
+
return { ...wallet, valuations: saved.valuations };
|
|
107
115
|
}
|
|
108
116
|
try {
|
|
109
117
|
await access(file);
|
|
@@ -119,7 +127,7 @@ export async function cardsToUse(catalog, file, io, named) {
|
|
|
119
127
|
io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
|
|
120
128
|
return null;
|
|
121
129
|
}
|
|
122
|
-
return loaded;
|
|
130
|
+
return loaded.cards.length === 0 ? 'none' : loaded;
|
|
123
131
|
}
|
|
124
132
|
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).`;
|
|
125
133
|
// The point values and memberships saved in the wallet, which every answer
|
|
@@ -157,6 +165,10 @@ export async function run(argv, io) {
|
|
|
157
165
|
return runBest(argv.slice(1), io);
|
|
158
166
|
if (argv[0] === 'cheatsheet')
|
|
159
167
|
return runCheatSheet(argv.slice(1), io);
|
|
168
|
+
if (argv[0] === 'todo')
|
|
169
|
+
return runTodo(argv.slice(1), io);
|
|
170
|
+
if (argv[0] === 'missed')
|
|
171
|
+
return runMissed(argv.slice(1), io);
|
|
160
172
|
if (argv[0] === 'simulate')
|
|
161
173
|
return runSimulate(argv.slice(1), io);
|
|
162
174
|
const command = argv[0];
|
|
@@ -194,14 +206,7 @@ export async function run(argv, io) {
|
|
|
194
206
|
io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
|
|
195
207
|
return USAGE_ERROR;
|
|
196
208
|
}
|
|
197
|
-
|
|
198
|
-
const resolvedName = resolveMerchant(catalog, purchase.merchant, purchase.channel);
|
|
199
|
-
const named = resolvedName.source === 'none' && options.category === null
|
|
200
|
-
? findCategory(catalog, purchase.merchant)
|
|
201
|
-
: null;
|
|
202
|
-
let merchant = named === null
|
|
203
|
-
? withCallerCategory(resolvedName, options.category)
|
|
204
|
-
: categoryResolution(catalog, named, purchase.channel);
|
|
209
|
+
let merchant = withCallerCategory(resolvePlace(catalog, purchase.merchant, purchase.channel), options.category);
|
|
205
210
|
const notices = [];
|
|
206
211
|
if (llmOn && merchant.source === 'none') {
|
|
207
212
|
const opened = await openLlmSession({
|
|
@@ -218,7 +223,14 @@ export async function run(argv, io) {
|
|
|
218
223
|
merchant = resolved.merchant;
|
|
219
224
|
notices.push(...resolved.notices);
|
|
220
225
|
}
|
|
226
|
+
const missedFile = missedPath(io.env);
|
|
227
|
+
if (missedFile !== null &&
|
|
228
|
+
(merchant.source === 'none' ||
|
|
229
|
+
merchant.source === 'caller' ||
|
|
230
|
+
merchant.source === 'llm'))
|
|
231
|
+
await missLogAt(missedFile).note(purchase.merchant, merchant.source === 'none' ? null : merchant.category, io.today);
|
|
221
232
|
if (loaded === 'none') {
|
|
233
|
+
const saved = await savedSettings(io);
|
|
222
234
|
const explored = exploreCards(catalog, {
|
|
223
235
|
merchant: purchase.merchant,
|
|
224
236
|
category: null,
|
|
@@ -227,10 +239,10 @@ export async function run(argv, io) {
|
|
|
227
239
|
date: purchase.date,
|
|
228
240
|
}, {
|
|
229
241
|
filters: ALL_PERSONAL_CARDS,
|
|
230
|
-
memberships: options.members,
|
|
242
|
+
memberships: options.members.length > 0 ? options.members : saved.memberships,
|
|
231
243
|
valuations: options.valuation
|
|
232
244
|
? VALUATION_PROFILES[options.valuation]
|
|
233
|
-
:
|
|
245
|
+
: saved.valuations,
|
|
234
246
|
}, merchant);
|
|
235
247
|
if (options.json) {
|
|
236
248
|
io.stdout(`${JSON.stringify(explored, null, 2)}\n`);
|
package/dist/cli/todo.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { CLI_NAME } from "../brand.js";
|
|
4
|
+
import { loadCatalog, walletPath } from "../data/load.js";
|
|
5
|
+
import { checklist, checklistCalendar } from "../engine/checklist.js";
|
|
6
|
+
import { cardText } from "../format/card-text.js";
|
|
7
|
+
import { dateProblem } from "./args.js";
|
|
8
|
+
import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
|
|
9
|
+
const TODO_HELP = `Usage: ${CLI_NAME} todo [--ics <file>] [--date <YYYY-MM-DD>]
|
|
10
|
+
|
|
11
|
+
What your saved cards need this period, soonest first: bonuses to activate,
|
|
12
|
+
categories to pick, spending caps running low, welcome bonuses to finish, and
|
|
13
|
+
rotating categories the issuer hasn't posted yet.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--ics <file> Also write the dated items as a calendar file to import
|
|
17
|
+
into your calendar app, with reminders two days before.
|
|
18
|
+
--date <YYYY-MM-DD> The day to check. Default: today.
|
|
19
|
+
-h, --help Show this help.
|
|
20
|
+
`;
|
|
21
|
+
export async function runTodo(argv, io) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = parseArgs({
|
|
25
|
+
args: argv,
|
|
26
|
+
options: {
|
|
27
|
+
ics: { type: 'string' },
|
|
28
|
+
date: { type: 'string' },
|
|
29
|
+
help: { type: 'boolean', short: 'h' },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
io.stderr(`${e instanceof Error ? e.message : String(e)}\n${TODO_HELP}`);
|
|
35
|
+
return USAGE_ERROR;
|
|
36
|
+
}
|
|
37
|
+
const { values } = parsed;
|
|
38
|
+
if (values.help) {
|
|
39
|
+
io.stdout(TODO_HELP);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
const problem = dateProblem(values.date);
|
|
43
|
+
if (problem !== null) {
|
|
44
|
+
io.stderr(`${problem}\n`);
|
|
45
|
+
return USAGE_ERROR;
|
|
46
|
+
}
|
|
47
|
+
const catalog = await loadCatalog();
|
|
48
|
+
const wallet = await readWallet(walletPath(io.env), io);
|
|
49
|
+
if (wallet === null)
|
|
50
|
+
return RUNTIME_ERROR;
|
|
51
|
+
const today = values.date ?? io.today;
|
|
52
|
+
const items = checklist(catalog, wallet, today, undefined, cardText(catalog).shortName);
|
|
53
|
+
io.stdout(items.length === 0
|
|
54
|
+
? 'Nothing to do: every bonus is activated, every pick is made, and no cap or welcome bonus needs attention.\n'
|
|
55
|
+
: `${items.map((i) => `- ${i.text}`).join('\n')}\n`);
|
|
56
|
+
if (values.ics !== undefined) {
|
|
57
|
+
const stamp = `${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
|
|
58
|
+
await writeFile(values.ics, checklistCalendar(items, stamp));
|
|
59
|
+
io.stdout(`Wrote ${displayPath(values.ics)}; open it to add the dates to your calendar.\n`);
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
package/dist/cli/wallet.js
CHANGED
|
@@ -3,6 +3,7 @@ import { CLI_NAME } from "../brand.js";
|
|
|
3
3
|
import { FileWalletStore } from "../data/file-store.js";
|
|
4
4
|
import { loadCatalog, loadWallet, walletPath } from "../data/load.js";
|
|
5
5
|
import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
|
|
6
|
+
import { membershipName } from "../engine/memberships.js";
|
|
6
7
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
7
8
|
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
8
9
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
@@ -41,19 +42,21 @@ ${CARD_OPTIONS}
|
|
|
41
42
|
`,
|
|
42
43
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
43
44
|
|
|
44
|
-
Changes a card in your wallet: picks for this period, label, anniversary
|
|
45
|
-
activations. Picks for the current period are merged with
|
|
45
|
+
Changes a card in your wallet: picks for this period, label, anniversary,
|
|
46
|
+
activations or welcome bonus. Picks for the current period are merged with
|
|
47
|
+
ones already set.
|
|
46
48
|
|
|
47
49
|
${CARD_OPTIONS}
|
|
48
50
|
`,
|
|
49
51
|
remove: `Usage: ${CLI_NAME} remove <card-id>
|
|
50
52
|
|
|
51
|
-
Removes a card from your wallet, with its tracked cap usage
|
|
53
|
+
Removes a card from your wallet, with its tracked cap usage and bonus
|
|
54
|
+
progress. Your memberships and point values stay.
|
|
52
55
|
`,
|
|
53
56
|
memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
|
|
54
57
|
|
|
55
58
|
With no arguments, shows your memberships and the ones earnrate knows. With
|
|
56
|
-
names, replaces the list. --none clears it.
|
|
59
|
+
names, replaces the list. --none clears it. No cards need to be saved first.
|
|
57
60
|
`,
|
|
58
61
|
values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
|
|
59
62
|
|
|
@@ -205,7 +208,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
205
208
|
if (command === 'memberships') {
|
|
206
209
|
if (positionals.length === 0 && !values.none) {
|
|
207
210
|
const held = await heldWallet(io);
|
|
208
|
-
io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
|
|
211
|
+
io.stdout(`Yours: ${held && held.memberships.length > 0 ? held.memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ') : 'none'}\nKnown: ${knownMemberships(catalog).join(', ')}\n`);
|
|
209
212
|
return 0;
|
|
210
213
|
}
|
|
211
214
|
if (positionals.length > 0 && values.none) {
|
|
@@ -213,7 +216,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
213
216
|
}
|
|
214
217
|
const list = values.none ? [] : positionals;
|
|
215
218
|
return report(await store.setMemberships(list), list.length > 0
|
|
216
|
-
? `Memberships set to ${list.join(', ')} in ${where}.`
|
|
219
|
+
? `Memberships set to ${list.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')} in ${where}.`
|
|
217
220
|
: `Memberships cleared in ${where}.`, null, io);
|
|
218
221
|
}
|
|
219
222
|
if (positionals.length !== 1) {
|
package/dist/data/file-store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
2
|
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
3
|
import { loadWallet } from "./load.js";
|
|
4
|
-
import {
|
|
4
|
+
import { emptyWallet, } from "./store.js";
|
|
5
|
+
import { addCardText, commitWalletText, EMPTY_WALLET_TEXT, readWalletText, removeCardText, setMembershipsText, setValuationsText, updateCardText, writeProgress, } from "./wallet-edit.js";
|
|
5
6
|
const failed = (error) => ({ ok: false, error });
|
|
6
7
|
const errorText = (e) => (e instanceof Error ? e.message : String(e));
|
|
7
8
|
const queues = new Map();
|
|
@@ -56,7 +57,7 @@ export class FileWalletStore {
|
|
|
56
57
|
}
|
|
57
58
|
});
|
|
58
59
|
}
|
|
59
|
-
//
|
|
60
|
+
// Changing or removing a card needs a wallet file to change.
|
|
60
61
|
existing(apply) {
|
|
61
62
|
return (wallet) => wallet === null
|
|
62
63
|
? `No wallet at ${this.where} yet. Add a card first to create it.`
|
|
@@ -69,15 +70,13 @@ export class FileWalletStore {
|
|
|
69
70
|
return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
|
|
70
71
|
}
|
|
71
72
|
removeCard(cardId) {
|
|
72
|
-
return this.change(this.existing((w) => w
|
|
73
|
-
? failed(`${cardId} is the last card in ${this.where}. To start over, delete that file; to swap cards, add the new one first.`)
|
|
74
|
-
: removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
73
|
+
return this.change(this.existing((w) => removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
75
74
|
}
|
|
76
75
|
setMemberships(memberships) {
|
|
77
|
-
return this.change(
|
|
76
|
+
return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships), (text, change) => setMembershipsText(text ?? EMPTY_WALLET_TEXT, change.wallet.memberships));
|
|
78
77
|
}
|
|
79
78
|
setValuations(changes) {
|
|
80
|
-
return this.change(
|
|
79
|
+
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
|
|
81
80
|
}
|
|
82
81
|
record(purchase, merchant) {
|
|
83
82
|
return withWalletLock(this.where, async () => {
|
package/dist/data/integrity.js
CHANGED
|
@@ -179,6 +179,7 @@ export function catalogProblems(catalog) {
|
|
|
179
179
|
['currency', catalog.currencies.map((c) => c.id)],
|
|
180
180
|
['card', catalog.cards.map((c) => c.id)],
|
|
181
181
|
['membership', catalog.memberships.map((m) => m.id)],
|
|
182
|
+
['issuer', catalog.issuers.map((i) => i.id)],
|
|
182
183
|
]) {
|
|
183
184
|
for (const id of duplicates(ids))
|
|
184
185
|
problems.push(`duplicate ${kind} id '${id}'`);
|
|
@@ -203,8 +204,11 @@ export function catalogProblems(catalog) {
|
|
|
203
204
|
for (const id of m.members_shop_at)
|
|
204
205
|
if (!merchantIds.has(id))
|
|
205
206
|
problems.push(`membership ${m.id}: members_shop_at '${id}' is not a merchant`);
|
|
207
|
+
const issuers = new Set(catalog.issuers.map((i) => i.id));
|
|
206
208
|
for (const card of catalog.cards) {
|
|
207
209
|
problems.push(...cardProblems(card, catalog, merchantIds));
|
|
210
|
+
if (!issuers.has(card.issuer))
|
|
211
|
+
problems.push(`${card.id}: unknown issuer '${card.issuer}' (data/issuers.yaml)`);
|
|
208
212
|
}
|
|
209
213
|
return problems;
|
|
210
214
|
}
|
package/dist/data/load.js
CHANGED
|
@@ -8,6 +8,7 @@ import { CONFIG_DIR, WALLET_ENV_VAR } from "../brand.js";
|
|
|
8
8
|
import { CardSchema } from "../schema/card.js";
|
|
9
9
|
import { CategoriesFileSchema } from "../schema/category.js";
|
|
10
10
|
import { CurrenciesFileSchema } from "../schema/currency.js";
|
|
11
|
+
import { IssuersFileSchema } from "../schema/issuer.js";
|
|
11
12
|
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
12
13
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
13
14
|
import { WalletSchema } from "../schema/wallet.js";
|
|
@@ -31,9 +32,10 @@ async function loadCards(dir) {
|
|
|
31
32
|
}));
|
|
32
33
|
}
|
|
33
34
|
export async function loadCatalog(dir = DATA_DIR) {
|
|
34
|
-
const [categories, currencies, memberships, cards, seed, overrides] = await Promise.all([
|
|
35
|
+
const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
|
|
35
36
|
readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
|
|
36
37
|
readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
|
|
38
|
+
readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
|
|
37
39
|
readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
|
|
38
40
|
loadCards(path.join(dir, 'cards')),
|
|
39
41
|
readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
|
|
@@ -42,6 +44,7 @@ export async function loadCatalog(dir = DATA_DIR) {
|
|
|
42
44
|
return {
|
|
43
45
|
categories,
|
|
44
46
|
currencies,
|
|
47
|
+
issuers,
|
|
45
48
|
memberships,
|
|
46
49
|
cards,
|
|
47
50
|
merchants: { seed, overrides },
|
package/dist/data/store.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { bookPurchase } from "../engine/record.js";
|
|
2
2
|
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.js";
|
|
3
|
-
import { WalletSchema } from "../schema/wallet.js";
|
|
3
|
+
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
4
|
const failed = (error) => ({ ok: false, error });
|
|
5
|
+
// Memberships and point values can be saved before any card.
|
|
6
|
+
export const emptyWallet = () => WalletSchema.parse({ version: WALLET_VERSION });
|
|
5
7
|
// A wallet stored as JSON text. Writes are serialized within this instance;
|
|
6
8
|
// a backend shared across processes must serialize them itself (one Durable
|
|
7
9
|
// Object per wallet does).
|
|
@@ -30,11 +32,7 @@ export class JsonWalletStore {
|
|
|
30
32
|
const change = apply(before);
|
|
31
33
|
if (!change.ok)
|
|
32
34
|
return failed(change.error);
|
|
33
|
-
|
|
34
|
-
if (change.wallet.cards.length === 0)
|
|
35
|
-
await this.backend.remove();
|
|
36
|
-
else
|
|
37
|
-
await this.backend.write(JSON.stringify(change.wallet));
|
|
35
|
+
await this.backend.write(JSON.stringify(change.wallet));
|
|
38
36
|
return {
|
|
39
37
|
ok: true,
|
|
40
38
|
wallet: change.wallet,
|
|
@@ -58,10 +56,10 @@ export class JsonWalletStore {
|
|
|
58
56
|
return this.change(this.needsWallet((w) => removeFromWallet(w, cardId)));
|
|
59
57
|
}
|
|
60
58
|
setMemberships(memberships) {
|
|
61
|
-
return this.change(
|
|
59
|
+
return this.change((w) => withMemberships(this.catalog, w ?? emptyWallet(), memberships));
|
|
62
60
|
}
|
|
63
61
|
setValuations(changes) {
|
|
64
|
-
return this.change(
|
|
62
|
+
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
|
|
65
63
|
}
|
|
66
64
|
replace(wallet) {
|
|
67
65
|
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
@@ -91,17 +89,3 @@ export class JsonWalletStore {
|
|
|
91
89
|
});
|
|
92
90
|
}
|
|
93
91
|
}
|
|
94
|
-
export function memoryBackend(initial = null) {
|
|
95
|
-
let text = initial;
|
|
96
|
-
return {
|
|
97
|
-
read: () => Promise.resolve(text),
|
|
98
|
-
write: (next) => {
|
|
99
|
-
text = next;
|
|
100
|
-
return Promise.resolve();
|
|
101
|
-
},
|
|
102
|
-
remove: () => {
|
|
103
|
-
text = null;
|
|
104
|
-
return Promise.resolve();
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
}
|
package/dist/data/wallet-edit.js
CHANGED
|
@@ -93,6 +93,17 @@ function cardsSeq(text) {
|
|
|
93
93
|
}
|
|
94
94
|
return { doc, cards };
|
|
95
95
|
}
|
|
96
|
+
function rootMap(text) {
|
|
97
|
+
const doc = parseDocument(text);
|
|
98
|
+
if (doc.errors.length > 0)
|
|
99
|
+
throw new Error(doc.errors[0]?.message);
|
|
100
|
+
const root = doc.contents;
|
|
101
|
+
if (!isMap(root))
|
|
102
|
+
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
103
|
+
return root;
|
|
104
|
+
}
|
|
105
|
+
// A wallet with no cards yet, for memberships or point values saved first.
|
|
106
|
+
export const EMPTY_WALLET_TEXT = `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards: []\n`;
|
|
96
107
|
function findCard(text, cardId) {
|
|
97
108
|
const { cards } = cardsSeq(text);
|
|
98
109
|
const card = cards.items.find((item) => isMap(item) && item.get('id') === cardId);
|
|
@@ -108,6 +119,17 @@ export function addCardText(text, entry) {
|
|
|
108
119
|
if (text === null || text.trim() === '') {
|
|
109
120
|
return `${NEW_WALLET}version: ${String(WALLET_VERSION)}\ncards:\n${cardLines(entry, ' ')}`;
|
|
110
121
|
}
|
|
122
|
+
const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
|
|
123
|
+
const value = pair?.value;
|
|
124
|
+
const none = value === null || (isSeq(value) && value.items.length === 0);
|
|
125
|
+
if (!pair || none) {
|
|
126
|
+
const block = `cards:\n${cardLines(entry, ' ')}`;
|
|
127
|
+
if (!pair)
|
|
128
|
+
return insertLines(text, text.length, block);
|
|
129
|
+
const start = lineStart(text, range(pair.key)[0]);
|
|
130
|
+
const end = afterLine(text, range(value ?? pair.key)[1] - 1);
|
|
131
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
132
|
+
}
|
|
111
133
|
const { cards } = cardsSeq(text);
|
|
112
134
|
const first = cards.items[0];
|
|
113
135
|
if (!isMap(first))
|
|
@@ -125,8 +147,13 @@ function cardLines(entry, dashIndent) {
|
|
|
125
147
|
}
|
|
126
148
|
export function removeCardText(text, cardId) {
|
|
127
149
|
const card = findCard(text, cardId);
|
|
128
|
-
const start = lineStart(text, range(card)[0]);
|
|
129
150
|
const end = afterLine(text, range(card)[1] - 1);
|
|
151
|
+
if (cardsSeq(text).cards.items.length === 1) {
|
|
152
|
+
const pair = rootMap(text).items.find((p) => keyOf(p) === 'cards');
|
|
153
|
+
const start = lineStart(text, range(pair?.key)[0]);
|
|
154
|
+
return `${text.slice(0, start)}cards: []\n${text.slice(end)}`;
|
|
155
|
+
}
|
|
156
|
+
const start = lineStart(text, range(card)[0]);
|
|
130
157
|
return text.slice(0, start) + text.slice(end);
|
|
131
158
|
}
|
|
132
159
|
// Replaces each given field in place, or adds it after the card's last line.
|
|
@@ -156,10 +183,7 @@ export function updateCardText(text, cardId, fields) {
|
|
|
156
183
|
return out;
|
|
157
184
|
}
|
|
158
185
|
export function setMembershipsText(text, memberships) {
|
|
159
|
-
const
|
|
160
|
-
const root = doc.contents;
|
|
161
|
-
if (!isMap(root))
|
|
162
|
-
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
186
|
+
const root = rootMap(text);
|
|
163
187
|
const line = `memberships: ${flowList(memberships)}\n`;
|
|
164
188
|
const pair = root.items.find((p) => keyOf(p) === 'memberships');
|
|
165
189
|
if (pair) {
|
|
@@ -168,7 +192,9 @@ export function setMembershipsText(text, memberships) {
|
|
|
168
192
|
return text.slice(0, start) + line + text.slice(end);
|
|
169
193
|
}
|
|
170
194
|
const cards = root.items.find((p) => keyOf(p) === 'cards');
|
|
171
|
-
return
|
|
195
|
+
return cards
|
|
196
|
+
? insertLines(text, lineStart(text, range(cards.key)[0]), line)
|
|
197
|
+
: insertLines(text, text.length, line);
|
|
172
198
|
}
|
|
173
199
|
// Point values, one currency at a time so the rest of the file, comments
|
|
174
200
|
// included, stays as it was: a value is replaced in place, a new one goes
|
|
@@ -192,10 +218,7 @@ function aboveComments(text, at) {
|
|
|
192
218
|
return start;
|
|
193
219
|
}
|
|
194
220
|
function setValuationText(text, id, cents) {
|
|
195
|
-
const
|
|
196
|
-
const root = doc.contents;
|
|
197
|
-
if (!isMap(root))
|
|
198
|
-
throw new Error(`the wallet isn't a map; ${BY_HAND}`);
|
|
221
|
+
const root = rootMap(text);
|
|
199
222
|
const pair = root.items.find((p) => keyOf(p) === 'valuations');
|
|
200
223
|
const map = pair?.value;
|
|
201
224
|
const line = cents === null ? '' : `${id}: ${String(cents)}`;
|
|
@@ -209,7 +232,9 @@ function setValuationText(text, id, cents) {
|
|
|
209
232
|
return text.slice(0, start) + block + text.slice(end);
|
|
210
233
|
}
|
|
211
234
|
const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
|
|
212
|
-
return
|
|
235
|
+
return before
|
|
236
|
+
? insertLines(text, aboveComments(text, range(before.key)[0]), block)
|
|
237
|
+
: insertLines(text, text.length, block);
|
|
213
238
|
}
|
|
214
239
|
if (!isMap(map))
|
|
215
240
|
throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
|
|
@@ -251,7 +276,7 @@ function flowUsage(booking) {
|
|
|
251
276
|
: booking.period;
|
|
252
277
|
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
253
278
|
}
|
|
254
|
-
|
|
279
|
+
function capUsageText(text, booking) {
|
|
255
280
|
const card = findCard(text, booking.cardId);
|
|
256
281
|
const value = flowUsage(booking);
|
|
257
282
|
const usage = card.get('cap_usage', true);
|
|
@@ -271,7 +296,7 @@ export function capUsageText(text, booking) {
|
|
|
271
296
|
const at = afterLine(text, range(card)[1] - 1);
|
|
272
297
|
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
273
298
|
}
|
|
274
|
-
|
|
299
|
+
function bonusSpentText(text, cardId, spent) {
|
|
275
300
|
const bonus = findCard(text, cardId).get('bonus', true);
|
|
276
301
|
if (!isMap(bonus))
|
|
277
302
|
throw new Error(`card '${cardId}' has no bonus`);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { resolveMerchantById } from "../merchants/resolve.js";
|
|
2
|
-
import {
|
|
1
|
+
import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
|
+
import { exploreCards } from "./explore.js";
|
|
3
3
|
import { bonusStatus } from "./bonus.js";
|
|
4
4
|
import { EPSILON, isClose } from "./compare.js";
|
|
5
5
|
import { periodKey } from "./periods.js";
|
|
6
6
|
import { recommend } from "./recommend.js";
|
|
7
|
+
import { checklist } from "./checklist.js";
|
|
7
8
|
import { setupNotes } from "./setup.js";
|
|
8
|
-
import { feeText, listed, money, shortDate } from "./text.js";
|
|
9
|
+
import { capitalize, feeText, listed, money, shortDate } from "./text.js";
|
|
9
10
|
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
10
11
|
// quick picks.
|
|
11
12
|
export const EVERYDAY_PURCHASES = [
|
|
@@ -82,10 +83,18 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
82
83
|
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
83
84
|
];
|
|
84
85
|
});
|
|
86
|
+
// Picks and activations in plain words, as the to-do list says them; the
|
|
87
|
+
// setup notes name ids for an agent to set.
|
|
88
|
+
const todo = checklist(catalog, wallet, date).filter((i) => i.kind === 'pick' || i.kind === 'activate');
|
|
85
89
|
const notes = [
|
|
86
90
|
...new Set([
|
|
87
91
|
...bonuses,
|
|
88
|
-
...
|
|
92
|
+
...todo.map((i) => i.text),
|
|
93
|
+
...wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date)
|
|
94
|
+
.filter((n) => n.kind !== 'pick' && n.kind !== 'activation')
|
|
95
|
+
.map((n) => n.kind === 'membership'
|
|
96
|
+
? n.text.replace(/ \([a-z0-9-]+\)/g, '')
|
|
97
|
+
: n.text)),
|
|
89
98
|
]),
|
|
90
99
|
];
|
|
91
100
|
const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
|
|
@@ -100,7 +109,6 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
100
109
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
101
110
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
102
111
|
// costs or needs.
|
|
103
|
-
const capitalized = (t) => t.charAt(0).toUpperCase() + t.slice(1);
|
|
104
112
|
export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
|
|
105
113
|
const rows = items.map((item) => {
|
|
106
114
|
const top = exploreCards(catalog, 'merchant' in item
|
|
@@ -119,7 +127,7 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
|
|
|
119
127
|
channel: null,
|
|
120
128
|
date,
|
|
121
129
|
}, options).ranked[0];
|
|
122
|
-
const fee = top ? `${
|
|
130
|
+
const fee = top ? `${capitalize(feeText(top))}.` : null;
|
|
123
131
|
return {
|
|
124
132
|
label: item.label,
|
|
125
133
|
cardId: top?.cardId ?? null,
|