earnrate 0.2.4 → 0.2.6
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 +18 -15
- package/data/CHANGELOG.md +79 -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 +23 -3
- package/data/cards/amex-platinum.yaml +19 -3
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +15 -3
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +19 -4
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +9 -2
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +10 -4
- 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 +50 -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 +19 -2
- package/data/cards/chase-ihg-one-rewards.yaml +15 -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 +17 -4
- 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 +6 -9
- package/data/cards/citi-costco-anywhere-visa.yaml +7 -10
- 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 +16 -3
- package/data/cards/td-bank-nordstrom-visa.yaml +23 -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/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/issuers.yaml +32 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +25 -0
- package/data/merchants/seed.yaml +7 -0
- package/dist/cli/bin.js +10 -1
- package/dist/cli/cheatsheet.js +11 -6
- package/dist/cli/explore.js +17 -23
- package/dist/cli/import.js +10 -10
- package/dist/cli/offers.js +19 -8
- package/dist/cli/run.js +31 -20
- package/dist/cli/wallet.js +50 -5
- package/dist/data/file-store.js +9 -7
- package/dist/data/integrity.js +22 -2
- package/dist/data/load.js +13 -2
- package/dist/data/store.js +13 -22
- package/dist/data/wallet-edit.js +108 -8
- package/dist/engine/cheat-sheet.js +15 -19
- package/dist/engine/compare.js +10 -0
- package/dist/engine/evaluate.js +2 -4
- package/dist/engine/explore.js +13 -56
- package/dist/engine/memberships.js +41 -15
- package/dist/engine/offers.js +18 -5
- package/dist/engine/periods.js +18 -0
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +23 -37
- package/dist/engine/setup.js +28 -40
- package/dist/engine/simulate.js +11 -10
- package/dist/engine/text.js +49 -23
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +26 -11
- package/dist/engine/wallet-summary.js +85 -31
- package/dist/format/card-text.js +19 -36
- package/dist/format/cards.js +3 -3
- package/dist/format/explore.js +15 -16
- package/dist/format/offers.js +10 -15
- package/dist/format/recommendation.js +6 -6
- package/dist/format/wallet.js +5 -2
- package/dist/import/analyze.js +2 -1
- package/dist/mcp/context.js +61 -13
- package/dist/mcp/inputs.js +16 -7
- package/dist/mcp/schemas.js +9 -0
- package/dist/mcp/server.js +8 -1
- package/dist/mcp/tools/account.js +45 -17
- package/dist/mcp/tools/answers.js +84 -29
- package/dist/mcp/tools/wallet.js +109 -22
- package/dist/merchants/cache.js +0 -10
- package/dist/merchants/resolve.js +81 -4
- package/dist/schema/card.js +8 -12
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/issuer.js +8 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +1 -1
- package/package.json +1 -1
package/dist/cli/wallet.js
CHANGED
|
@@ -2,6 +2,8 @@ import { parseArgs } from 'node:util';
|
|
|
2
2
|
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
|
+
import { isValuationProfile, profileText, VALUATION_PROFILES, } from "../engine/profiles.js";
|
|
6
|
+
import { membershipName } from "../engine/memberships.js";
|
|
5
7
|
import { knownMemberships, searchCards } from "../engine/setup.js";
|
|
6
8
|
import { formatCardInfo, formatNotes } from "../format/cards.js";
|
|
7
9
|
import { displayPath, RUNTIME_ERROR, USAGE_ERROR } from "./run.js";
|
|
@@ -40,19 +42,35 @@ ${CARD_OPTIONS}
|
|
|
40
42
|
`,
|
|
41
43
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
42
44
|
|
|
43
|
-
Changes a card in your wallet: picks for this period, label, anniversary
|
|
44
|
-
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.
|
|
45
48
|
|
|
46
49
|
${CARD_OPTIONS}
|
|
47
50
|
`,
|
|
48
51
|
remove: `Usage: ${CLI_NAME} remove <card-id>
|
|
49
52
|
|
|
50
|
-
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.
|
|
51
55
|
`,
|
|
52
56
|
memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
|
|
53
57
|
|
|
54
58
|
With no arguments, shows your memberships and the ones earnrate knows. With
|
|
55
|
-
names, replaces the list. --none clears it.
|
|
59
|
+
names, replaces the list. --none clears it. No cards need to be saved first.
|
|
60
|
+
`,
|
|
61
|
+
values: `Usage: ${CLI_NAME} values [<currency>=<cents>...] [--profile <name>]
|
|
62
|
+
|
|
63
|
+
What your points are worth to you, in cents per point, saved in your wallet
|
|
64
|
+
file; every answer then uses them unless you pass --valuation. With no
|
|
65
|
+
arguments, shows them.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
${CLI_NAME} values UR=1.5 MR=1.6
|
|
69
|
+
${CLI_NAME} values UR=default back to earnrate's value
|
|
70
|
+
${CLI_NAME} values --profile points-heavy
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
--profile <name> flat (${profileText('flat')}) or points-heavy (${profileText('points-heavy')}).
|
|
56
74
|
`,
|
|
57
75
|
};
|
|
58
76
|
const HINTS = {
|
|
@@ -118,6 +136,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
118
136
|
'bonus-by': { type: 'string' },
|
|
119
137
|
'no-bonus': { type: 'boolean' },
|
|
120
138
|
none: { type: 'boolean' },
|
|
139
|
+
profile: { type: 'string' },
|
|
121
140
|
help: { type: 'boolean', short: 'h' },
|
|
122
141
|
},
|
|
123
142
|
});
|
|
@@ -160,6 +179,32 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
160
179
|
io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
|
|
161
180
|
return 0;
|
|
162
181
|
}
|
|
182
|
+
if (command === 'values') {
|
|
183
|
+
const changes = {};
|
|
184
|
+
if (values.profile !== undefined) {
|
|
185
|
+
if (!isValuationProfile(values.profile))
|
|
186
|
+
return usage(`Unknown profile "${values.profile}". Use flat or points-heavy.`);
|
|
187
|
+
Object.assign(changes, VALUATION_PROFILES[values.profile]);
|
|
188
|
+
}
|
|
189
|
+
for (const arg of positionals) {
|
|
190
|
+
const [id, value] = arg.split('=');
|
|
191
|
+
const cents = value === 'default' ? null : Number(value);
|
|
192
|
+
if (!id ||
|
|
193
|
+
value === undefined ||
|
|
194
|
+
(cents !== null && !Number.isFinite(cents)))
|
|
195
|
+
return usage(`"${arg}" isn't <currency>=<cents>, like UR=1.5.`);
|
|
196
|
+
changes[id] = cents;
|
|
197
|
+
}
|
|
198
|
+
if (Object.keys(changes).length === 0) {
|
|
199
|
+
const held = await heldWallet(io);
|
|
200
|
+
const set = Object.entries(held?.valuations ?? {});
|
|
201
|
+
io.stdout(`${set.length > 0 ? `Yours: ${set.map(([id, v]) => `${id}=${typeof v === 'number' ? String(v) : JSON.stringify(v)}`).join(' ')}` : "Yours: earnrate's defaults"}\nCurrencies: ${catalog.currencies.map((c) => c.id).join(', ')}\n`);
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
return report(await store.setValuations(changes), `Point values updated in ${where}: ${Object.entries(changes)
|
|
205
|
+
.map(([id, c]) => `${id} ${c === null ? 'back to the default' : `${String(c)}¢`}`)
|
|
206
|
+
.join(', ')}.`, null, io);
|
|
207
|
+
}
|
|
163
208
|
if (command === 'memberships') {
|
|
164
209
|
if (positionals.length === 0 && !values.none) {
|
|
165
210
|
const held = await heldWallet(io);
|
|
@@ -171,7 +216,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
171
216
|
}
|
|
172
217
|
const list = values.none ? [] : positionals;
|
|
173
218
|
return report(await store.setMemberships(list), list.length > 0
|
|
174
|
-
? `Memberships set to ${list.join(', ')} in ${where}.`
|
|
219
|
+
? `Memberships set to ${list.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')} in ${where}.`
|
|
175
220
|
: `Memberships cleared in ${where}.`, null, io);
|
|
176
221
|
}
|
|
177
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
|
-
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, } from "../engine/wallet-change.js";
|
|
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,12 +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));
|
|
77
|
+
}
|
|
78
|
+
setValuations(changes) {
|
|
79
|
+
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes), (text) => setValuationsText(text ?? EMPTY_WALLET_TEXT, changes));
|
|
78
80
|
}
|
|
79
81
|
record(purchase, merchant) {
|
|
80
82
|
return withWalletLock(this.where, async () => {
|
package/dist/data/integrity.js
CHANGED
|
@@ -60,8 +60,19 @@ function cardProblems(card, catalog, merchantIds) {
|
|
|
60
60
|
for (const id of card.welcome_offer?.not_with ?? [])
|
|
61
61
|
if (!cardIds.has(id))
|
|
62
62
|
problems.push(`${where} welcome_offer.not_with: unknown card '${id}'`);
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const memberships = new Set(catalog.memberships.map((m) => m.id));
|
|
64
|
+
for (const id of [
|
|
65
|
+
...(card.requires_membership === undefined
|
|
66
|
+
? []
|
|
67
|
+
: [card.requires_membership]),
|
|
68
|
+
...card.rules.flatMap((r) => r.requires?.membership ?? []),
|
|
69
|
+
])
|
|
70
|
+
if (!memberships.has(id))
|
|
71
|
+
problems.push(`${where}: unknown membership '${id}' (data/memberships.yaml)`);
|
|
72
|
+
if (card.requires_membership !== undefined &&
|
|
73
|
+
catalog.memberships.find((m) => m.id === card.requires_membership)
|
|
74
|
+
?.annual_fee === undefined)
|
|
75
|
+
problems.push(`${where}: requires_membership '${card.requires_membership}' has no annual_fee`);
|
|
65
76
|
if (card.pools_into) {
|
|
66
77
|
if (!currencies.has(card.pools_into.currency)) {
|
|
67
78
|
problems.push(`${where} pools_into: unknown currency '${card.pools_into.currency}'`);
|
|
@@ -167,6 +178,8 @@ export function catalogProblems(catalog) {
|
|
|
167
178
|
['category', catalog.categories.map((c) => c.id)],
|
|
168
179
|
['currency', catalog.currencies.map((c) => c.id)],
|
|
169
180
|
['card', catalog.cards.map((c) => c.id)],
|
|
181
|
+
['membership', catalog.memberships.map((m) => m.id)],
|
|
182
|
+
['issuer', catalog.issuers.map((i) => i.id)],
|
|
170
183
|
]) {
|
|
171
184
|
for (const id of duplicates(ids))
|
|
172
185
|
problems.push(`duplicate ${kind} id '${id}'`);
|
|
@@ -187,8 +200,15 @@ export function catalogProblems(catalog) {
|
|
|
187
200
|
problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
|
|
188
201
|
}
|
|
189
202
|
}
|
|
203
|
+
for (const m of catalog.memberships)
|
|
204
|
+
for (const id of m.members_shop_at)
|
|
205
|
+
if (!merchantIds.has(id))
|
|
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));
|
|
190
208
|
for (const card of catalog.cards) {
|
|
191
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)`);
|
|
192
212
|
}
|
|
193
213
|
return problems;
|
|
194
214
|
}
|
package/dist/data/load.js
CHANGED
|
@@ -8,6 +8,8 @@ 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";
|
|
12
|
+
import { MembershipsFileSchema } from "../schema/membership.js";
|
|
11
13
|
import { MerchantsFileSchema } from "../schema/merchant.js";
|
|
12
14
|
import { WalletSchema } from "../schema/wallet.js";
|
|
13
15
|
export const DATA_DIR = fileURLToPath(new URL('../../data/', import.meta.url));
|
|
@@ -30,14 +32,23 @@ async function loadCards(dir) {
|
|
|
30
32
|
}));
|
|
31
33
|
}
|
|
32
34
|
export async function loadCatalog(dir = DATA_DIR) {
|
|
33
|
-
const [categories, currencies, cards, seed, overrides] = await Promise.all([
|
|
35
|
+
const [categories, currencies, issuers, memberships, cards, seed, overrides] = await Promise.all([
|
|
34
36
|
readYaml(path.join(dir, 'categories.yaml'), CategoriesFileSchema),
|
|
35
37
|
readYaml(path.join(dir, 'currencies.yaml'), CurrenciesFileSchema),
|
|
38
|
+
readYaml(path.join(dir, 'issuers.yaml'), IssuersFileSchema),
|
|
39
|
+
readYaml(path.join(dir, 'memberships.yaml'), MembershipsFileSchema),
|
|
36
40
|
loadCards(path.join(dir, 'cards')),
|
|
37
41
|
readYaml(path.join(dir, 'merchants', 'seed.yaml'), MerchantsFileSchema),
|
|
38
42
|
readYaml(path.join(dir, 'merchants', 'overrides.yaml'), MerchantsFileSchema),
|
|
39
43
|
]);
|
|
40
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
categories,
|
|
46
|
+
currencies,
|
|
47
|
+
issuers,
|
|
48
|
+
memberships,
|
|
49
|
+
cards,
|
|
50
|
+
merchants: { seed, overrides },
|
|
51
|
+
};
|
|
41
52
|
}
|
|
42
53
|
// MCP clients pass config values through without a shell, so "~" or
|
|
43
54
|
// "${HOME}" can arrive as literal text; expand them here.
|
package/dist/data/store.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
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";
|
|
2
|
+
import { addToWallet, removeFromWallet, updateInWallet, withMemberships, withValuations, } from "../engine/wallet-change.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,7 +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));
|
|
60
|
+
}
|
|
61
|
+
setValuations(changes) {
|
|
62
|
+
return this.change((w) => withValuations(this.catalog, w ?? emptyWallet(), changes));
|
|
62
63
|
}
|
|
63
64
|
replace(wallet) {
|
|
64
65
|
return this.change(() => ({ ok: true, wallet, fields: {}, notes: [] }));
|
|
@@ -70,6 +71,10 @@ export class JsonWalletStore {
|
|
|
70
71
|
disconnect() {
|
|
71
72
|
return Promise.resolve();
|
|
72
73
|
}
|
|
74
|
+
// The backend doesn't record when it began.
|
|
75
|
+
started() {
|
|
76
|
+
return Promise.resolve(null);
|
|
77
|
+
}
|
|
73
78
|
record(purchase, merchant) {
|
|
74
79
|
return this.serialized(async () => {
|
|
75
80
|
const wallet = await this.load();
|
|
@@ -84,17 +89,3 @@ export class JsonWalletStore {
|
|
|
84
89
|
});
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
|
-
export function memoryBackend(initial = null) {
|
|
88
|
-
let text = initial;
|
|
89
|
-
return {
|
|
90
|
-
read: () => Promise.resolve(text),
|
|
91
|
-
write: (next) => {
|
|
92
|
-
text = next;
|
|
93
|
-
return Promise.resolve();
|
|
94
|
-
},
|
|
95
|
-
remove: () => {
|
|
96
|
-
text = null;
|
|
97
|
-
return Promise.resolve();
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
}
|
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,83 @@ 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);
|
|
198
|
+
}
|
|
199
|
+
// Point values, one currency at a time so the rest of the file, comments
|
|
200
|
+
// included, stays as it was: a value is replaced in place, a new one goes
|
|
201
|
+
// after the last, a removed one loses its line. An inline map is rewritten.
|
|
202
|
+
export function setValuationsText(text, changes) {
|
|
203
|
+
let out = text;
|
|
204
|
+
for (const [id, cents] of Object.entries(changes))
|
|
205
|
+
out = setValuationText(out, id, cents);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
// The start of the line at `at`, moved up past the comments right above it,
|
|
209
|
+
// so a comment stays with the line it describes.
|
|
210
|
+
function aboveComments(text, at) {
|
|
211
|
+
let start = lineStart(text, at);
|
|
212
|
+
while (start > 0) {
|
|
213
|
+
const previous = lineStart(text, start - 1);
|
|
214
|
+
if (!text.slice(previous, start).trimStart().startsWith('#'))
|
|
215
|
+
break;
|
|
216
|
+
start = previous;
|
|
217
|
+
}
|
|
218
|
+
return start;
|
|
219
|
+
}
|
|
220
|
+
function setValuationText(text, id, cents) {
|
|
221
|
+
const root = rootMap(text);
|
|
222
|
+
const pair = root.items.find((p) => keyOf(p) === 'valuations');
|
|
223
|
+
const map = pair?.value;
|
|
224
|
+
const line = cents === null ? '' : `${id}: ${String(cents)}`;
|
|
225
|
+
if (!pair || map === null || (isMap(map) && map.items.length === 0)) {
|
|
226
|
+
if (cents === null)
|
|
227
|
+
return text;
|
|
228
|
+
const block = `valuations:\n ${line}\n`;
|
|
229
|
+
if (pair) {
|
|
230
|
+
const start = range(pair.key)[0];
|
|
231
|
+
const end = afterLine(text, range(map ?? pair.key)[1] - 1);
|
|
232
|
+
return text.slice(0, start) + block + text.slice(end);
|
|
233
|
+
}
|
|
234
|
+
const before = root.items.find((p) => keyOf(p) === 'memberships' || keyOf(p) === 'cards');
|
|
235
|
+
return before
|
|
236
|
+
? insertLines(text, aboveComments(text, range(before.key)[0]), block)
|
|
237
|
+
: insertLines(text, text.length, block);
|
|
238
|
+
}
|
|
239
|
+
if (!isMap(map))
|
|
240
|
+
throw new Error(`"valuations" isn't a map; ${BY_HAND}`);
|
|
241
|
+
if (map.flow) {
|
|
242
|
+
const values = Object.entries(map.toJSON()).filter(([k]) => k !== id);
|
|
243
|
+
if (cents !== null)
|
|
244
|
+
values.push([id, cents]);
|
|
245
|
+
const entries = values.map(([k, v]) => `${k}: ${stringify(v, { flow: true }).trimEnd()}`);
|
|
246
|
+
const start = range(pair.key)[0];
|
|
247
|
+
const end = afterLine(text, range(map)[1] - 1);
|
|
248
|
+
return (text.slice(0, start) +
|
|
249
|
+
(entries.length === 0 ? '' : `valuations: { ${entries.join(', ')} }\n`) +
|
|
250
|
+
text.slice(end));
|
|
251
|
+
}
|
|
252
|
+
const own = map.items.find((p) => keyOf(p) === id);
|
|
253
|
+
if (own) {
|
|
254
|
+
if (cents === null) {
|
|
255
|
+
if (map.items.length === 1) {
|
|
256
|
+
const start = lineStart(text, range(pair.key)[0]);
|
|
257
|
+
return (text.slice(0, start) + text.slice(afterLine(text, range(map)[1] - 1)));
|
|
258
|
+
}
|
|
259
|
+
const start = lineStart(text, range(own.key)[0]);
|
|
260
|
+
return (text.slice(0, start) +
|
|
261
|
+
text.slice(afterLine(text, range(own.value)[1] - 1)));
|
|
262
|
+
}
|
|
263
|
+
const [start, end] = range(own.value);
|
|
264
|
+
return text.slice(0, start) + String(cents) + text.slice(end);
|
|
265
|
+
}
|
|
266
|
+
if (cents === null)
|
|
267
|
+
return text;
|
|
268
|
+
const first = map.items[0];
|
|
269
|
+
const last = map.items.at(-1);
|
|
270
|
+
const indent = ' '.repeat(column(text, range(first?.key)[0]));
|
|
271
|
+
return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
|
|
172
272
|
}
|
|
173
273
|
function flowUsage(booking) {
|
|
174
274
|
const period = /^\d+$/.test(booking.period)
|
|
@@ -176,7 +276,7 @@ function flowUsage(booking) {
|
|
|
176
276
|
: booking.period;
|
|
177
277
|
return `{ period: ${period}, spent: ${String(booking.after)} }`;
|
|
178
278
|
}
|
|
179
|
-
|
|
279
|
+
function capUsageText(text, booking) {
|
|
180
280
|
const card = findCard(text, booking.cardId);
|
|
181
281
|
const value = flowUsage(booking);
|
|
182
282
|
const usage = card.get('cap_usage', true);
|
|
@@ -196,7 +296,7 @@ export function capUsageText(text, booking) {
|
|
|
196
296
|
const at = afterLine(text, range(card)[1] - 1);
|
|
197
297
|
return insertLines(text, at, `${indent}cap_usage:\n${indent} ${booking.capKey}: ${value}\n`);
|
|
198
298
|
}
|
|
199
|
-
|
|
299
|
+
function bonusSpentText(text, cardId, spent) {
|
|
200
300
|
const bonus = findCard(text, cardId).get('bonus', true);
|
|
201
301
|
if (!isMap(bonus))
|
|
202
302
|
throw new Error(`card '${cardId}' has no bonus`);
|
|
@@ -1,11 +1,14 @@
|
|
|
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
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
4
5
|
import { periodKey } from "./periods.js";
|
|
5
6
|
import { recommend } from "./recommend.js";
|
|
6
7
|
import { setupNotes } from "./setup.js";
|
|
7
|
-
import { listed, money, shortDate } from "./text.js";
|
|
8
|
-
|
|
8
|
+
import { capitalize, feeText, listed, money, shortDate } from "./text.js";
|
|
9
|
+
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
10
|
+
// quick picks.
|
|
11
|
+
export const EVERYDAY_PURCHASES = [
|
|
9
12
|
{ label: 'Groceries', category: 'grocery.supermarket' },
|
|
10
13
|
{ label: 'Dining', category: 'dining' },
|
|
11
14
|
{ label: 'Gas', category: 'gas' },
|
|
@@ -18,7 +21,7 @@ const CHEAT_SHEET_ITEMS = [
|
|
|
18
21
|
];
|
|
19
22
|
// The best card in the wallet for each everyday kind of purchase on `date`,
|
|
20
23
|
// with caps, picks and activations as the wallet has them.
|
|
21
|
-
export function cheatSheet(catalog, wallet, date, items =
|
|
24
|
+
export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
22
25
|
const ranked = [];
|
|
23
26
|
const rows = items.map((item) => {
|
|
24
27
|
const merchant = 'merchant' in item
|
|
@@ -53,8 +56,8 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
53
56
|
const name = held.checkout_label ??
|
|
54
57
|
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
55
58
|
held.id;
|
|
56
|
-
// Rows where the bonus card matches the row's card, or
|
|
57
|
-
//
|
|
59
|
+
// Rows where the bonus card matches the row's card, or is close to it
|
|
60
|
+
// (as single answers mention it).
|
|
58
61
|
const matches = [];
|
|
59
62
|
const near = [];
|
|
60
63
|
for (const { label, cards } of ranked) {
|
|
@@ -62,9 +65,9 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
62
65
|
const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
|
|
63
66
|
if (best === undefined || own === undefined)
|
|
64
67
|
continue;
|
|
65
|
-
if (best - own <
|
|
68
|
+
if (best - own < EPSILON)
|
|
66
69
|
matches.push(label.toLowerCase());
|
|
67
|
-
else if (
|
|
70
|
+
else if (isClose(own, best))
|
|
68
71
|
near.push(label.toLowerCase());
|
|
69
72
|
}
|
|
70
73
|
const where = [
|
|
@@ -97,14 +100,11 @@ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
|
|
|
97
100
|
// The best card earnrate knows for each everyday kind of purchase, for
|
|
98
101
|
// someone who hasn't named any cards. Each row's note says what the card
|
|
99
102
|
// costs or needs.
|
|
100
|
-
export function catalogCheatSheet(catalog, date, options, items =
|
|
103
|
+
export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCHASES) {
|
|
101
104
|
const rows = items.map((item) => {
|
|
102
105
|
const top = exploreCards(catalog, 'merchant' in item
|
|
103
106
|
? {
|
|
104
|
-
merchant: catalog
|
|
105
|
-
?.name ??
|
|
106
|
-
catalog.merchants.overrides.find((m) => m.id === item.merchant)
|
|
107
|
-
?.name ??
|
|
107
|
+
merchant: resolveMerchantById(catalog, item.merchant, null)?.resolvedName ??
|
|
108
108
|
item.label,
|
|
109
109
|
category: null,
|
|
110
110
|
amount: null,
|
|
@@ -118,11 +118,7 @@ export function catalogCheatSheet(catalog, date, options, items = CHEAT_SHEET_IT
|
|
|
118
118
|
channel: null,
|
|
119
119
|
date,
|
|
120
120
|
}, options).ranked[0];
|
|
121
|
-
const fee = top
|
|
122
|
-
? null
|
|
123
|
-
: top.annualFee === 0
|
|
124
|
-
? 'No annual fee.'
|
|
125
|
-
: `$${String(top.annualFee)} annual fee.`;
|
|
121
|
+
const fee = top ? `${capitalize(feeText(top))}.` : null;
|
|
126
122
|
return {
|
|
127
123
|
label: item.label,
|
|
128
124
|
cardId: top?.cardId ?? null,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Returns closer than this are equal: floating-point noise, not a difference.
|
|
2
|
+
export const EPSILON = 1e-9;
|
|
3
|
+
// Dollar amounts closer than half a cent print the same.
|
|
4
|
+
export const HALF_CENT = 0.005;
|
|
5
|
+
// A card close to the best one: within a percentage point, and at least
|
|
6
|
+
// three quarters of its rate. Answers and cheat sheets mention a welcome
|
|
7
|
+
// bonus card when it's this close.
|
|
8
|
+
export function isClose(own, best) {
|
|
9
|
+
return best - own <= 1 && own >= best * 0.75;
|
|
10
|
+
}
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { periodKey } from "./periods.js";
|
|
1
|
+
import { inEffect, periodKey } from "./periods.js";
|
|
2
2
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
3
3
|
return {
|
|
4
4
|
category,
|
|
@@ -110,9 +110,7 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
110
110
|
return 'no';
|
|
111
111
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
112
112
|
return 'no';
|
|
113
|
-
if (rule
|
|
114
|
-
return 'no';
|
|
115
|
-
if (rule.valid_to !== undefined && ctx.date > rule.valid_to)
|
|
113
|
+
if (!inEffect(rule, ctx.date))
|
|
116
114
|
return 'no';
|
|
117
115
|
const membership = rule.requires?.membership;
|
|
118
116
|
if (membership !== undefined && !ctx.memberships.has(membership))
|