earnrate 0.1.3 → 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.
@@ -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, } from "../engine/offers.js";
4
+ import { compareOffers } from "../engine/offers.js";
6
5
  import { isValuationProfile, withValuationProfile } from "../engine/profiles.js";
7
- import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
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 walletFile = walletPath(io.env);
102
- const loaded = await readWallet(walletFile, io);
103
- if (!loaded)
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 problems = walletProblems(loaded, catalog);
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,25 +1,31 @@
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";
12
15
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
13
16
  import { packageVersion } from "../version.js";
14
17
  import { parseCliArgs } from "./args.js";
15
- import { runBest, runSimulate } from "./explore.js";
18
+ import { runCheatSheet } from "./cheatsheet.js";
19
+ import { formatExploration, runBest, runSimulate } from "./explore.js";
16
20
  import { runImport } from "./import.js";
17
21
  import { runOffers } from "./offers.js";
18
22
  import { runWalletCommand, WALLET_HELP } from "./wallet.js";
19
23
  import { formatRecommendation, plain } from "./format.js";
20
24
  export const HELP = `Usage: ${CLI_NAME} <merchant> [amount] [options]
21
25
 
22
- Picks the card in your wallet that earns the most at a merchant, and says why.
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.
23
29
 
24
30
  Examples:
25
31
  ${CLI_NAME} whole foods 80
@@ -38,6 +44,7 @@ Set up your wallet:
38
44
  ${CLI_NAME} memberships [names...] Show or set memberships (amazon-prime).
39
45
 
40
46
  Other commands:
47
+ ${CLI_NAME} cheatsheet Your best card for each category this quarter.
41
48
  ${CLI_NAME} import <statement.csv> --card <id> Replay a statement against your wallet.
42
49
  ${CLI_NAME} offers [--cash | --points] Rank welcome offers on cards you don't have.
43
50
  ${CLI_NAME} mcp Run the MCP server over stdio, for AI agents.
@@ -51,6 +58,9 @@ Options:
51
58
  --json Print the full recommendation as JSON.
52
59
  --date <YYYY-MM-DD> Purchase date. Default: today.
53
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.
54
64
  --category <id> For a merchant earnrate doesn't know: its category,
55
65
  e.g. grocery.supermarket. Ignored for known merchants.
56
66
  --llm, --no-llm Ask Claude to classify merchants earnrate doesn't know.
@@ -73,6 +83,38 @@ export function displayPath(file) {
73
83
  const home = homedir();
74
84
  return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
75
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).`;
76
118
  export async function readWallet(file, io) {
77
119
  try {
78
120
  return await loadWallet(file);
@@ -94,6 +136,8 @@ export async function run(argv, io) {
94
136
  return runOffers(argv.slice(1), io);
95
137
  if (argv[0] === 'best')
96
138
  return runBest(argv.slice(1), io);
139
+ if (argv[0] === 'cheatsheet')
140
+ return runCheatSheet(argv.slice(1), io);
97
141
  if (argv[0] === 'simulate')
98
142
  return runSimulate(argv.slice(1), io);
99
143
  const command = argv[0];
@@ -116,18 +160,10 @@ export async function run(argv, io) {
116
160
  const { options } = args;
117
161
  const llmOn = options.llm ?? llmEnabled(io.env);
118
162
  const file = walletPath(io.env);
119
- const loaded = await readWallet(file, io);
120
- if (!loaded)
163
+ const loaded = await cardsToUse(file, io, options);
164
+ if (loaded === null)
121
165
  return RUNTIME_ERROR;
122
166
  const catalog = await loadCatalog();
123
- const problems = walletProblems(loaded, catalog);
124
- if (problems.length > 0) {
125
- io.stderr(`Your wallet at ${displayPath(file)} has problems:\n${problems.map((p) => ` - ${p}\n`).join('')}`);
126
- return RUNTIME_ERROR;
127
- }
128
- const wallet = options.valuation
129
- ? withValuationProfile(loaded, options.valuation)
130
- : loaded;
131
167
  const purchase = {
132
168
  merchant: options.merchant,
133
169
  amount: options.amount,
@@ -140,7 +176,14 @@ export async function run(argv, io) {
140
176
  io.stderr(`Unknown category "${options.category}". Use one of: ${catalog.categories.map((c) => c.id).join(', ')}.\n`);
141
177
  return USAGE_ERROR;
142
178
  }
143
- let merchant = withCallerCategory(resolveMerchant(catalog, purchase.merchant, purchase.channel), options.category);
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);
144
187
  const notices = [];
145
188
  if (llmOn && merchant.source === 'none') {
146
189
  const opened = await openLlmSession({
@@ -157,6 +200,33 @@ export async function run(argv, io) {
157
200
  merchant = resolved.merchant;
158
201
  notices.push(...resolved.notices);
159
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;
160
230
  const rec = recommend(catalog, wallet, purchase, merchant);
161
231
  if (options.json) {
162
232
  io.stdout(`${JSON.stringify(rec, null, 2)}\n`);
@@ -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
- return env[WALLET_ENV_VAR] ?? path.join(homedir(), CONFIG_DIR, 'wallet.yaml');
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
+ }
@@ -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 = ['checkout_label', 'anniversary', 'chosen', 'activated'];
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);
@@ -0,0 +1,103 @@
1
+ import { resolveMerchantById } from "../merchants/resolve.js";
2
+ import { categoryResolution, exploreCards, } from "./explore.js";
3
+ import { periodKey } from "./periods.js";
4
+ import { recommend } from "./recommend.js";
5
+ import { setupNotes } from "./setup.js";
6
+ export const CHEAT_SHEET_ITEMS = [
7
+ { label: 'Groceries', category: 'grocery.supermarket' },
8
+ { label: 'Dining', category: 'dining' },
9
+ { label: 'Gas', category: 'gas' },
10
+ { label: 'Amazon', merchant: 'amazon' },
11
+ { label: 'Costco', merchant: 'costco' },
12
+ { label: 'Travel', category: 'travel' },
13
+ { label: 'Streaming', category: 'streaming' },
14
+ { label: 'Drugstores', category: 'drugstore' },
15
+ { label: 'Everything else', category: 'other' },
16
+ ];
17
+ // The best card in the wallet for each everyday kind of purchase on `date`,
18
+ // with caps, picks and activations as the wallet has them.
19
+ export function cheatSheet(catalog, wallet, date, items = CHEAT_SHEET_ITEMS) {
20
+ const rows = items.map((item) => {
21
+ const merchant = 'merchant' in item
22
+ ? resolveMerchantById(catalog, item.merchant, null)
23
+ : categoryResolution(catalog, item.category, null);
24
+ if (!merchant)
25
+ throw new Error(`unknown merchant '${item.label}'`);
26
+ const rec = recommend(catalog, wallet, {
27
+ merchant: merchant.input,
28
+ amount: null,
29
+ channel: null,
30
+ date,
31
+ country: 'US',
32
+ }, merchant);
33
+ const top = rec.ranked.find((r) => r.cardId === rec.decision?.cardId);
34
+ return {
35
+ label: item.label,
36
+ cardId: top?.cardId ?? null,
37
+ cardName: top ? (top.checkoutLabel ?? top.cardName) : null,
38
+ effectiveReturnPct: top?.effectiveReturnPct ?? null,
39
+ matchedRule: top?.matchedRule ?? null,
40
+ note: top?.warnings[0] ?? null,
41
+ };
42
+ });
43
+ const notes = [
44
+ ...new Set(wallet.cards.flatMap((held) => setupNotes(catalog, wallet, held, date).map((n) => n.text))),
45
+ ];
46
+ const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
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
+ };
103
+ }