earnrate 0.2.6 → 0.2.8

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.
@@ -147,14 +147,14 @@ function capState(rule, card, held, date) {
147
147
  const { period, spent } = capUsed(cap, key, held, date);
148
148
  return { key, cap, period, remaining: Math.max(0, cap.amount - spent) };
149
149
  }
150
- function rateAfterCap(rate, cap, amount) {
150
+ function rateAfterCap(rate, cap, afterCap, amount) {
151
151
  if (!cap)
152
152
  return rate;
153
153
  if (amount !== null && amount > 0) {
154
154
  const inCap = Math.min(amount, cap.remaining);
155
- return (inCap * rate + (amount - inCap) * cap.cap.then_rate) / amount;
155
+ return (inCap * rate + (amount - inCap) * afterCap) / amount;
156
156
  }
157
- return cap.remaining > 0 ? rate : cap.cap.then_rate;
157
+ return cap.remaining > 0 ? rate : afterCap;
158
158
  }
159
159
  // On equal rates an uncapped rule wins, so spend that can earn the same
160
160
  // without a cap doesn't use one up. Otherwise the earlier rule stays.
@@ -170,24 +170,31 @@ export function evaluateCard(card, held, ctx) {
170
170
  nominalRate: card.base_rate,
171
171
  rate: card.base_rate,
172
172
  cap: null,
173
+ afterCap: null,
173
174
  };
174
175
  let best = base;
175
176
  let nominal = base;
176
177
  const blocked = [];
178
+ const applying = [];
177
179
  for (const rule of card.rules) {
178
180
  const status = applicability(card, rule, ctx, held, picked);
179
181
  if (status === 'no')
180
182
  continue;
181
- if (status === 'needs-activation') {
183
+ if (status === 'needs-activation')
182
184
  blocked.push(rule);
183
- continue;
184
- }
185
+ else
186
+ applying.push(rule);
187
+ }
188
+ const uncapped = Math.max(card.base_rate, ...applying.filter((r) => r.cap === undefined).map((r) => r.rate));
189
+ for (const rule of applying) {
185
190
  const cap = capState(rule, card, held, ctx.date);
191
+ const afterCap = cap ? Math.max(cap.cap.then_rate, uncapped) : null;
186
192
  const earning = {
187
193
  rule,
188
194
  nominalRate: rule.rate,
189
- rate: rateAfterCap(rule.rate, cap, ctx.amount),
195
+ rate: rateAfterCap(rule.rate, cap, afterCap ?? rule.rate, ctx.amount),
190
196
  cap,
197
+ afterCap,
191
198
  };
192
199
  if (beats(earning, earning.rate, best, best.rate))
193
200
  best = earning;
@@ -5,7 +5,7 @@ import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { recommend } from "./recommend.js";
6
6
  import { rotationGap } from "./rotation.js";
7
7
  import { membershipsUsed } from "./setup.js";
8
- import { optionName } from "./wallet-summary.js";
8
+ import { optionName } from "./rule-text.js";
9
9
  import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
10
10
  import { cardValuation } from "./valuation.js";
11
11
  import { EPSILON } from "./compare.js";
@@ -99,7 +99,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
99
99
  ? {
100
100
  amount: cap.amount,
101
101
  period: cap.period,
102
- thenRate: cap.then_rate,
102
+ thenRate: best.afterCap ?? cap.then_rate,
103
103
  currency: cardValuation(catalog, wallet, card).currency,
104
104
  }
105
105
  : null,
@@ -1,7 +1,9 @@
1
1
  import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
2
2
  import { bonusStatus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
+ import { membershipName } from "./memberships.js";
4
5
  import { daysBetween } from "./periods.js";
6
+ import { ruleWhat } from "./rule-text.js";
5
7
  import { rotationGap } from "./rotation.js";
6
8
  import { EPSILON, isClose } from "./compare.js";
7
9
  import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
@@ -84,7 +86,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
84
86
  const currency = valuation.currency;
85
87
  if (best.rule) {
86
88
  const note = best.rule.note ? ` ${best.rule.note}` : '';
87
- lines.push(`${earnText(catalog, best.nominalRate, currency)} from its "${best.rule.id}" rule.${note}`);
89
+ lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
88
90
  }
89
91
  else {
90
92
  lines.push(`${earnText(catalog, card.base_rate, currency)}, its base rate. No bonus category applies here.`);
@@ -92,7 +94,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
92
94
  const cap = best.cap;
93
95
  if (cap && amount !== null && amount > cap.remaining) {
94
96
  const over = amount - cap.remaining;
95
- lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
97
+ lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
96
98
  }
97
99
  if (valuation.pooledFrom !== null) {
98
100
  const partners = card.pools_into?.when_wallet_has ?? [];
@@ -121,17 +123,17 @@ function warningsFor(catalog, entry, purchase) {
121
123
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
122
124
  }
123
125
  else if (purchase.amount !== null && purchase.amount > remaining) {
124
- warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
126
+ warnings.push(`This purchase crosses the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)}: only ${money(remaining)} is left for ${period}.`);
125
127
  }
126
128
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
127
- warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
129
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on ${ruleWhat(catalog, best.rule)} for ${period}.`);
128
130
  }
129
131
  }
130
132
  if (nominal.rule &&
131
133
  nominal.cap &&
132
134
  nominal !== best &&
133
135
  nominal.cap.remaining === 0) {
134
- warnings.push(`The ${capLabel(nominal)} on "${nominal.rule.id}" is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
136
+ warnings.push(`The ${capLabel(nominal)} on ${ruleWhat(catalog, nominal.rule)} is used up for ${nominal.cap.period ?? 'this period'}, so it earns ${earnText(catalog, nominal.afterCap ?? nominal.cap.cap.then_rate, valuation.currency)} until it resets.`);
135
137
  }
136
138
  if (best.rule?.unverified !== undefined) {
137
139
  warnings.push(`This rate isn't confirmed against the issuer's terms yet: ${best.rule.unverified}`);
@@ -144,6 +146,10 @@ function warningsFor(catalog, entry, purchase) {
144
146
  warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
145
147
  }
146
148
  for (const rule of evaluation.needsActivation) {
149
+ if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
150
+ warnings.push(`The bonus here (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
151
+ continue;
152
+ }
147
153
  warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
148
154
  }
149
155
  return warnings;
@@ -0,0 +1,47 @@
1
+ import { findMerchant } from "../merchants/resolve.js";
2
+ import { categoryPhrase, choiceName, listed, words } from "./text.js";
3
+ // What a rule or a pick covers, in words, for answers and wallet lists.
4
+ // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
5
+ // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
6
+ const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
7
+ function merchantName(catalog, id) {
8
+ const merchant = findMerchant(catalog, id);
9
+ const name = merchant?.name ?? words(id);
10
+ return merchant?.kind === 'booking'
11
+ ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
12
+ : name;
13
+ }
14
+ export function optionName(catalog, card, choiceId, option) {
15
+ const o = card.choices.find((c) => c.id === choiceId)?.options[option];
16
+ if (!o)
17
+ return words(option);
18
+ const parts = [
19
+ ...o.categories.map((c) => categoryPhrase(catalog, c)),
20
+ ...o.merchants.map((m) => merchantName(catalog, m)),
21
+ ];
22
+ if (o.online)
23
+ parts.push('online shopping');
24
+ return parts.length > 0 ? listed(parts) : words(option);
25
+ }
26
+ // What a rule covers, as a list of phrases.
27
+ export function ruleParts(catalog, rule) {
28
+ if (rule.match.any)
29
+ return ['everything else'];
30
+ if (rule.match.choice !== undefined)
31
+ return [`your ${choiceName(rule.match.choice)} picks`];
32
+ const merchants = rule.match.merchants ?? [];
33
+ // A long list of one kind of store reads as "select streaming services".
34
+ const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
35
+ const [kind] = kinds;
36
+ const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
37
+ ? [`select ${categoryPhrase(catalog, kind)}`]
38
+ : merchants.map((m) => merchantName(catalog, m));
39
+ const parts = [
40
+ ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
41
+ ...merchantParts,
42
+ ];
43
+ return parts.length > 0 ? parts : [words(rule.id)];
44
+ }
45
+ export function ruleWhat(catalog, rule) {
46
+ return listed(ruleParts(catalog, rule));
47
+ }
@@ -45,6 +45,7 @@ export function activationRules(card, today) {
45
45
  return card.rules
46
46
  .filter((r) => r.requires?.activation === true &&
47
47
  (r.valid_to === undefined || r.valid_to >= today) &&
48
+ (r.activate_by === undefined || r.activate_by >= today) &&
48
49
  (r.valid_from === undefined ||
49
50
  daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
50
51
  .map((r) => r.id);
@@ -1,3 +1,4 @@
1
+ import { CARD_NUMBER_LABEL, labelHasCardNumber } from "../schema/card-number.js";
1
2
  import { WALLET_VERSION, } from "../schema/wallet.js";
2
3
  import { addMonths } from "./bonus.js";
3
4
  import { capByKey } from "./evaluate.js";
@@ -33,9 +34,8 @@ export function cardFields(card, held, input, today) {
33
34
  else if (input.checkoutLabel !== undefined) {
34
35
  if (input.checkoutLabel.trim() === '')
35
36
  problems.push('The checkout label is empty.');
36
- // earnrate never stores card numbers; a last four is fine.
37
- if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
38
- problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
37
+ if (labelHasCardNumber(input.checkoutLabel))
38
+ problems.push(CARD_NUMBER_LABEL);
39
39
  fields.checkout_label = input.checkoutLabel.trim();
40
40
  }
41
41
  if (input.anniversary !== undefined) {
@@ -1,66 +1,19 @@
1
- import { findMerchant } from "../merchants/resolve.js";
2
1
  import { bonusStatus } from "./bonus.js";
3
2
  import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
4
3
  import { membershipName } from "./memberships.js";
4
+ import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
5
5
  import { daysBetween, inEffect, periodEnd } from "./periods.js";
6
6
  import { STALE_AFTER_DAYS } from "./recommend.js";
7
7
  import { rotationGap } from "./rotation.js";
8
8
  import { activationRules } from "./setup.js";
9
- import { CAP_PERIODS, categoryPhrase, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
9
+ import { CAP_PERIODS, choiceName, currencyLabel, earnText, listed, shortDate, wholeDollars, words, } from "./text.js";
10
10
  import { cardValuation, currencyValuation } from "./valuation.js";
11
- // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
12
- // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
13
- const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
14
- function merchantName(catalog, id) {
15
- const merchant = findMerchant(catalog, id);
16
- const name = merchant?.name ?? words(id);
17
- return merchant?.kind === 'booking'
18
- ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
19
- : name;
20
- }
21
- export function optionName(catalog, card, choiceId, option) {
22
- const o = card.choices.find((c) => c.id === choiceId)?.options[option];
23
- if (!o)
24
- return words(option);
25
- const parts = [
26
- ...o.categories.map((c) => categoryPhrase(catalog, c)),
27
- ...o.merchants.map((m) => merchantName(catalog, m)),
28
- ];
29
- if (o.online)
30
- parts.push('online shopping');
31
- return parts.length > 0 ? listed(parts) : words(option);
32
- }
33
- // What a rule covers, as a list of phrases.
34
- function ruleParts(catalog, rule) {
35
- if (rule.match.choice !== undefined)
36
- return [`your ${choiceName(rule.match.choice)} picks`];
37
- const merchants = rule.match.merchants ?? [];
38
- // A long list of one kind of store reads as "select streaming services".
39
- const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
40
- const [kind] = kinds;
41
- const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
42
- ? [`select ${categoryPhrase(catalog, kind)}`]
43
- : merchants.map((m) => merchantName(catalog, m));
44
- const parts = [
45
- ...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
46
- ...merchantParts,
47
- ];
48
- return parts.length > 0 ? parts : [words(rule.id)];
49
- }
50
- function ruleWhat(catalog, rule) {
51
- return listed(ruleParts(catalog, rule));
52
- }
53
11
  const matchKeys = (rule) => rule.match.choice !== undefined
54
12
  ? []
55
13
  : [
56
14
  ...(rule.match.categories ?? []).map((c) => `category:${c}`),
57
15
  ...(rule.match.merchants ?? []).map((m) => `merchant:${m}`),
58
16
  ];
59
- // Every bonus rate the cardholder gets now, best first, so none reads as the
60
- // base rate. Rates behind a membership the wallet lacks are left out, and so
61
- // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
62
- // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
63
- // a line.
64
17
  // "first $1,500 a quarter, combined": how much of a rate's spending earns it.
65
18
  function capLimit(card, shown, rule) {
66
19
  if (rule.cap === undefined)
@@ -74,6 +27,11 @@ function capLimit(card, shown, rule) {
74
27
  shown.filter((r) => r.cap === rule.cap).length > 1;
75
28
  return `first ${wholeDollars(cap.amount)} ${CAP_PERIODS[cap.period]}${shared ? ', combined' : ''}`;
76
29
  }
30
+ // Every bonus rate the cardholder gets now, best first, so none reads as the
31
+ // base rate. Rates behind a membership the wallet lacks are left out, and so
32
+ // is a lower tier that an uncapped, always-on higher rate fully covers (Prime
33
+ // Visa's 3% without Prime, once Prime is held). Rules at the same rate share
34
+ // a line.
77
35
  function headlineRates(catalog, wallet, card, held, currency, today) {
78
36
  const shown = card.rules
79
37
  .filter((r) => inEffect(r, today) &&
@@ -91,6 +49,16 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
91
49
  .flatMap(matchKeys);
92
50
  if (keys.length > 0 && keys.every((k) => higher.includes(k)))
93
51
  continue;
52
+ // A membership tier's higher rate on the same purchases replaces the
53
+ // card's own (BofA Rewards tiers), cap or not.
54
+ const replaced = shown.some((h) => h !== r &&
55
+ h.rate > r.rate &&
56
+ h.requires?.membership !== undefined &&
57
+ r.requires?.membership === undefined &&
58
+ JSON.stringify(h.match) === JSON.stringify(r.match) &&
59
+ JSON.stringify(h.exclude) === JSON.stringify(r.exclude));
60
+ if (replaced)
61
+ continue;
94
62
  const pending = r.requires?.activation && !isActivated(card, held, r.id);
95
63
  const key = [
96
64
  `${earnText(catalog, r.rate, currency)} on`,
@@ -109,10 +77,14 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
109
77
  const after = `${limit}${activate}`;
110
78
  return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
111
79
  }),
112
- `${earnText(catalog, card.base_rate, currency)} on everything else`,
80
+ ...(shown.some((r) => r.match.any && r.rate > card.base_rate)
81
+ ? []
82
+ : [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
113
83
  ];
114
84
  }
115
- function toActivate(catalog, card, held, today) {
85
+ // Bonuses current or starting soon that the card hasn't activated, one per
86
+ // activation group (one activation covers a quarter's categories).
87
+ export function activationsDue(catalog, card, held, today) {
116
88
  const seen = new Set();
117
89
  const out = [];
118
90
  for (const id of activationRules(card, today)) {
@@ -123,18 +95,25 @@ function toActivate(catalog, card, held, today) {
123
95
  seen.add(g);
124
96
  const rules = card.rules.filter((r) => group.includes(r.id));
125
97
  const first = rules[0];
126
- const deadline = first?.activate_by === undefined
127
- ? ''
128
- : `, activate by ${shortDate(first.activate_by)}`;
129
- const when = first?.valid_from !== undefined && first.valid_to !== undefined
130
- ? ` (${shortDate(first.valid_from)} to ${shortDate(first.valid_to)}${deadline})`
131
- : deadline === ''
132
- ? ''
133
- : ` (${deadline.slice(2)})`;
134
- out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
98
+ out.push({
99
+ what: rules.map((r) => ruleWhat(catalog, r)).join(', '),
100
+ from: first?.valid_from ?? null,
101
+ to: first?.valid_to ?? null,
102
+ activateBy: first?.activate_by ?? null,
103
+ });
135
104
  }
136
105
  return out;
137
106
  }
107
+ function toActivate(catalog, card, held, today) {
108
+ return activationsDue(catalog, card, held, today).map((a) => {
109
+ const deadline = a.activateBy === null ? '' : `activate by ${shortDate(a.activateBy)}`;
110
+ const dates = a.from !== null && a.to !== null
111
+ ? `${shortDate(a.from)} to ${shortDate(a.to)}`
112
+ : '';
113
+ const when = [dates, deadline].filter((x) => x !== '').join(', ');
114
+ return `${a.what}${when === '' ? '' : ` (${when})`}`;
115
+ });
116
+ }
138
117
  function capSummaries(catalog, card, held, today) {
139
118
  const caps = new Map();
140
119
  for (const shared of card.caps)
@@ -1,5 +1,6 @@
1
1
  import { Document, parse, visit } from 'yaml';
2
2
  import { walletProblems } from "../data/integrity.js";
3
+ import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
3
4
  import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
4
5
  // A wallet as the YAML file the CLI reads, for moving it between the
5
6
  // website, the remote server and the command line. Node-free.
@@ -54,7 +55,10 @@ export function parseWalletYaml(text, catalog) {
54
55
  }
55
56
  const result = WalletSchema.safeParse(raw);
56
57
  if (!result.success)
57
- return "That isn't an earnrate wallet.";
58
+ return (result.error.issues
59
+ .map((i) => i.message)
60
+ .find((m) => m === CARD_NUMBER_LABEL || m === CARD_NUMBER_REDEMPTION) ??
61
+ "That isn't an earnrate wallet.");
58
62
  const problems = walletProblems(result.data, catalog);
59
63
  return problems.length > 0 ? problems.join(' ') : result.data;
60
64
  }
@@ -1,4 +1,10 @@
1
1
  import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
2
+ // "To do now", above the card details; nothing when all's done.
3
+ export function formatChecklist(items) {
4
+ return items.length === 0
5
+ ? ''
6
+ : `To do now:\n${items.map((i) => `- ${i.text}`).join('\n')}\n\n`;
7
+ }
2
8
  export function formatWalletSummary(summary) {
3
9
  const cards = summary.cards.map((c) => {
4
10
  const label = c.checkoutLabel
@@ -61,10 +61,14 @@ export function makeContext(deps) {
61
61
  // neither ever overrides a merchant earnrate already knows.
62
62
  async function resolve(merchant, channel, category) {
63
63
  const known = resolvePlace(catalog, merchant, channel);
64
- if (known.source !== 'none' || category !== null || !deps.llm) {
65
- return { merchant: withCallerCategory(known, category), notices: [] };
66
- }
67
- return deps.llm.resolve(catalog, merchant, channel);
64
+ const resolved = known.source !== 'none' || category !== null || !deps.llm
65
+ ? { merchant: withCallerCategory(known, category), notices: [] }
66
+ : await deps.llm.resolve(catalog, merchant, channel);
67
+ const source = resolved.merchant.source;
68
+ if (deps.missed &&
69
+ (source === 'none' || source === 'caller' || source === 'llm'))
70
+ await deps.missed.note(merchant, source === 'none' ? null : resolved.merchant.category, deps.today());
71
+ return resolved;
68
72
  }
69
73
  // Cards named in the conversation come first, then the saved wallet; with
70
74
  // neither (null), answers cover every card earnrate knows.
@@ -86,6 +86,14 @@ export const WalletSummarySchema = z.object({
86
86
  redemption: z.string().nullable(),
87
87
  })),
88
88
  started: z.string().nullable(),
89
+ checklist: z
90
+ .array(z.object({
91
+ kind: z.enum(['activate', 'pick', 'cap', 'bonus', 'rotation']),
92
+ cardId: z.string(),
93
+ text: z.string(),
94
+ due: z.string().nullable(),
95
+ }))
96
+ .optional(),
89
97
  cards: z.array(z.object({
90
98
  cardId: z.string(),
91
99
  cardName: z.string(),
package/dist/mcp/serve.js CHANGED
@@ -3,12 +3,15 @@ import { FileWalletStore } from "../data/file-store.js";
3
3
  import { loadCatalog, walletPath } from "../data/load.js";
4
4
  import { localToday as today } from "../engine/periods.js";
5
5
  import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
6
+ import { missedPath, missLogAt } from "../merchants/missed.js";
6
7
  import { packageVersion } from "../version.js";
7
8
  import { createEarnrateServer } from "./server.js";
8
9
  // stdout carries the protocol, so diagnostics go to stderr only.
9
10
  export async function serveMcp(env) {
10
11
  const version = await packageVersion();
11
12
  const catalog = await loadCatalog();
13
+ const missedFile = missedPath(env);
14
+ const missed = missedFile === null ? null : missLogAt(missedFile);
12
15
  let llm = null;
13
16
  if (llmEnabled(env)) {
14
17
  const opened = await openLlmSession({ env, today });
@@ -26,6 +29,7 @@ export async function serveMcp(env) {
26
29
  savedWallets: 'file',
27
30
  today,
28
31
  llm,
32
+ missed,
29
33
  version,
30
34
  }));
31
35
  }
@@ -39,7 +39,7 @@ const WHICH_WALLET = {
39
39
  };
40
40
  const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
41
41
 
42
- Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
42
+ Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. After answering, offer once to save those cards so earnrate remembers them, and save them with add_card only if the user says yes. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing. When the user asks what to do with their cards (this quarter, or what they're forgetting), call list_wallet: it starts with a to-do list.
43
43
 
44
44
  The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
45
45
 
@@ -3,8 +3,11 @@ import { membershipsFor, needsAnniversary } from "../../engine/setup.js";
3
3
  import { choiceName, currencyLabel, listed, money, shortDate, words, } from "../../engine/text.js";
4
4
  import { membershipName } from "../../engine/memberships.js";
5
5
  import { profileText, VALUATION_PROFILES } from "../../engine/profiles.js";
6
- import { optionName, walletSummary } from "../../engine/wallet-summary.js";
7
- import { formatBooking, formatWalletSummary } from "../../format/wallet.js";
6
+ import { optionName } from "../../engine/rule-text.js";
7
+ import { walletSummary } from "../../engine/wallet-summary.js";
8
+ import { checklist } from "../../engine/checklist.js";
9
+ import { cardText } from "../../format/card-text.js";
10
+ import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
8
11
  import { fail } from "../context.js";
9
12
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
10
13
  import { RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "../schemas.js";
@@ -52,7 +55,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
52
55
  const { catalog, store } = deps;
53
56
  server.registerTool('list_wallet', {
54
57
  title: 'List wallet',
55
- description: `Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
58
+ description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
56
59
  outputSchema: WalletSummarySchema,
57
60
  annotations: {
58
61
  title: 'List wallet',
@@ -64,9 +67,11 @@ export function registerWalletTools(server, deps, ctx, inputs) {
64
67
  if (typeof read === 'string')
65
68
  return fail(read);
66
69
  const started = deps.savedWallets === 'account' ? await deps.store.started() : null;
70
+ const base = walletSummary(catalog, read.wallet, deps.today());
67
71
  const summary = {
68
- ...walletSummary(catalog, read.wallet, deps.today()),
72
+ ...base,
69
73
  started,
74
+ checklist: checklist(catalog, read.wallet, deps.today(), base, cardText(catalog).shortName),
70
75
  };
71
76
  const when = started === null
72
77
  ? `This is ${ctx.walletName}.\n`
@@ -75,7 +80,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
75
80
  content: [
76
81
  {
77
82
  type: 'text',
78
- text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatWalletSummary(summary)}`,
83
+ text: `${when}${read.start === null ? '' : `${read.start}\n`}${formatChecklist(summary.checklist)}${formatWalletSummary(summary)}`,
79
84
  },
80
85
  ],
81
86
  structuredContent: summary,
@@ -1,4 +1,5 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { writeFileAtomic } from "../data/atomic-write.js";
2
3
  import { homedir } from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { z } from 'zod';
@@ -71,10 +72,7 @@ export async function openMerchantCache(file) {
71
72
  merchants = { ...merchants, [key]: toEntry(value) };
72
73
  if (!writable)
73
74
  return;
74
- await mkdir(path.dirname(file), { recursive: true });
75
- const tmp = `${file}.${String(process.pid)}.tmp`;
76
- await writeFile(tmp, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
77
- await rename(tmp, file);
75
+ await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants }, null, 2)}\n`);
78
76
  },
79
77
  },
80
78
  };
@@ -1,4 +1,5 @@
1
1
  import { LLM_MAX_CALLS_ENV_VAR } from "../brand.js";
2
+ import { hasCardNumber } from "../schema/card-number.js";
2
3
  import { describeClassifierError } from "./anthropic.js";
3
4
  import { normalizeMerchantName, resolveMerchant } from "./resolve.js";
4
5
  export const DEFAULT_MAX_CALLS = 20;
@@ -42,6 +43,13 @@ export function createLlmSession(options) {
42
43
  const known = resolveMerchant(catalog, input, channel);
43
44
  if (known.source !== 'none')
44
45
  return { merchant: known, notices: [] };
46
+ if (hasCardNumber(input))
47
+ return {
48
+ merchant: known,
49
+ notices: [
50
+ 'Skipped the Claude lookup: that looks like a card number, and earnrate never sends one anywhere.',
51
+ ],
52
+ };
45
53
  const key = normalizeMerchantName(input);
46
54
  const saved = options.cache.get(key);
47
55
  if (saved && usable(catalog, saved)) {
@@ -0,0 +1,77 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { writeFileAtomic } from "../data/atomic-write.js";
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { CONFIG_DIR, MISSED_ENV_VAR } from "../brand.js";
7
+ import { hasCardNumber } from "../schema/card-number.js";
8
+ import { normalizeMerchantName } from "./resolve.js";
9
+ const MissedFileSchema = z.object({
10
+ version: z.literal(1),
11
+ merchants: z.record(z.string(), z.object({
12
+ name: z.string(),
13
+ count: z.number().int().positive(),
14
+ last_seen: z.string(),
15
+ categories: z.array(z.string()).default([]),
16
+ })),
17
+ });
18
+ const MAX = 500;
19
+ // $EARNRATE_MISSED_MERCHANTS names another file, or "off" keeps no log.
20
+ export function missedPath(env = process.env) {
21
+ const given = env[MISSED_ENV_VAR]?.trim();
22
+ if (given === 'off')
23
+ return null;
24
+ return given || path.join(homedir(), CONFIG_DIR, 'missed-merchants.json');
25
+ }
26
+ export async function readMissed(file) {
27
+ try {
28
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
29
+ return parsed.success ? Object.values(parsed.data.merchants) : [];
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ // Writes are queued, so concurrent tool calls never lose a count. A file that
36
+ // can't be read as a log is left alone.
37
+ export function missLogAt(file) {
38
+ let queue = Promise.resolve();
39
+ return {
40
+ note(name, category, today) {
41
+ const key = normalizeMerchantName(name);
42
+ if (key === '' || hasCardNumber(name))
43
+ return Promise.resolve();
44
+ const run = queue.then(async () => {
45
+ let merchants = {};
46
+ try {
47
+ const parsed = MissedFileSchema.safeParse(JSON.parse(await readFile(file, 'utf8')));
48
+ if (!parsed.success)
49
+ return;
50
+ merchants = parsed.data.merchants;
51
+ }
52
+ catch (e) {
53
+ if (!(e instanceof Error && 'code' in e && e.code === 'ENOENT'))
54
+ return;
55
+ }
56
+ const seen = merchants[key];
57
+ merchants[key] = {
58
+ name: name.trim(),
59
+ count: (seen?.count ?? 0) + 1,
60
+ last_seen: today,
61
+ categories: [
62
+ ...new Set([
63
+ ...(seen?.categories ?? []),
64
+ ...(category === null ? [] : [category]),
65
+ ]),
66
+ ],
67
+ };
68
+ const kept = Object.entries(merchants)
69
+ .sort(([, a], [, b]) => b.last_seen.localeCompare(a.last_seen))
70
+ .slice(0, MAX);
71
+ await writeFileAtomic(file, `${JSON.stringify({ version: 1, merchants: Object.fromEntries(kept) }, null, 2)}\n`);
72
+ });
73
+ queue = run.catch(() => undefined);
74
+ return run.catch(() => undefined);
75
+ },
76
+ };
77
+ }