earnrate 0.1.4 → 0.2.1

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.
Files changed (53) hide show
  1. package/README.md +52 -17
  2. package/data/CHANGELOG.md +8 -0
  3. package/data/VERSION +1 -1
  4. package/data/categories.yaml +22 -7
  5. package/dist/brand.js +2 -3
  6. package/dist/cli/args.js +67 -45
  7. package/dist/cli/cheatsheet.js +36 -35
  8. package/dist/cli/explore.js +47 -163
  9. package/dist/cli/import.js +3 -8
  10. package/dist/cli/offers.js +14 -56
  11. package/dist/cli/run.js +90 -20
  12. package/dist/cli/wallet.js +47 -6
  13. package/dist/data/file-store.js +95 -0
  14. package/dist/data/load.js +10 -2
  15. package/dist/data/store.js +92 -0
  16. package/dist/data/wallet-edit.js +93 -4
  17. package/dist/engine/bonus.js +15 -12
  18. package/dist/engine/cheat-sheet.js +59 -4
  19. package/dist/engine/evaluate.js +34 -10
  20. package/dist/engine/explore.js +16 -28
  21. package/dist/engine/offers.js +8 -21
  22. package/dist/engine/periods.js +9 -0
  23. package/dist/engine/recommend.js +17 -53
  24. package/dist/engine/record.js +34 -4
  25. package/dist/engine/simulate.js +33 -19
  26. package/dist/engine/text.js +53 -0
  27. package/dist/engine/transient.js +26 -0
  28. package/dist/engine/valuation.js +1 -1
  29. package/dist/engine/wallet-change.js +36 -15
  30. package/dist/engine/wallet-summary.js +4 -8
  31. package/dist/format/card-text.js +79 -0
  32. package/dist/format/cheat-sheet.js +19 -0
  33. package/dist/format/explore.js +114 -0
  34. package/dist/format/offers.js +33 -0
  35. package/dist/{cli/format.js → format/recommendation.js} +9 -11
  36. package/dist/format/wallet-yaml.js +45 -0
  37. package/dist/format/wallet.js +38 -0
  38. package/dist/import/analyze.js +17 -19
  39. package/dist/mcp/context.js +92 -0
  40. package/dist/mcp/inputs.js +148 -0
  41. package/dist/mcp/schemas.js +8 -2
  42. package/dist/mcp/serve.js +3 -1
  43. package/dist/mcp/server.js +43 -660
  44. package/dist/mcp/tools/account.js +71 -0
  45. package/dist/mcp/tools/answers.js +329 -0
  46. package/dist/mcp/tools/wallet.js +128 -0
  47. package/dist/merchants/from-env.js +1 -1
  48. package/dist/merchants/resolve.js +3 -3
  49. package/dist/schema/catalog.js +1 -0
  50. package/package.json +8 -3
  51. package/dist/data/wallet-setup.js +0 -60
  52. package/dist/data/wallet-write.js +0 -140
  53. /package/dist/{cli → format}/cards.js +0 -0
@@ -1,9 +1,10 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { periodKey } from "./periods.js";
4
- import { categoryPhrase, currencyLabel, dollars, earnText, num, recommend, } from "./recommend.js";
3
+ import { choicePeriod, periodKey } from "./periods.js";
4
+ import { recommend } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  import { membershipsUsed } from "./setup.js";
7
+ import { categoryPhrase, codesAs, earnText, money, pointValuesText, } from "./text.js";
7
8
  import { cardValuation } from "./valuation.js";
8
9
  const EPSILON = 1e-9;
9
10
  export const ALL_PERSONAL_CARDS = {
@@ -29,18 +30,11 @@ export function matchesFilters(card, filters) {
29
30
  return false;
30
31
  return true;
31
32
  }
32
- // The period a card's picks are keyed by in a wallet.
33
- export function choicePeriod(card) {
34
- for (const c of card.choices)
35
- if (c.period !== 'until_changed')
36
- return c.period;
37
- return 'quarter';
38
- }
39
33
  // A card as its holder would set it up for the best result: the given picks,
40
34
  // every activation done, and no cap used yet. The anniversary is `date`, so a
41
35
  // cardmember-year cap starts fresh.
42
36
  export function idealHolding(card, date, picks = {}) {
43
- const period = choicePeriod(card);
37
+ const period = choicePeriod(card) ?? 'calendar_year';
44
38
  return {
45
39
  id: card.id,
46
40
  anniversary: date.slice(5),
@@ -92,7 +86,7 @@ export function categoryResolution(catalog, category, channel) {
92
86
  ambiguity: null,
93
87
  channel,
94
88
  confidence: 'high',
95
- source: 'caller',
89
+ source: 'category',
96
90
  bookedVia: [],
97
91
  inference: null,
98
92
  };
@@ -163,7 +157,7 @@ function conditionsText(catalog, r) {
163
157
  if (r.membership !== null)
164
158
  conditions.push(`Needs ${r.membership}.`);
165
159
  if (r.cap) {
166
- conditions.push(`Up to ${dollars(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
160
+ conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
167
161
  }
168
162
  return conditions;
169
163
  }
@@ -198,10 +192,11 @@ function compareExplored(a, b) {
198
192
  }
199
193
  // Ranks every card in the catalog that passes the filters for one purchase,
200
194
  // each set up at its best: the pick that covers it, activated, cap unused.
201
- export function exploreCards(catalog, query, options) {
202
- const merchant = query.merchant !== null
203
- ? resolveMerchant(catalog, query.merchant, query.channel)
204
- : categoryResolution(catalog, query.category ?? 'other', query.channel);
195
+ export function exploreCards(catalog, query, options, resolved) {
196
+ const merchant = resolved ??
197
+ (query.merchant !== null
198
+ ? resolveMerchant(catalog, query.merchant, query.channel)
199
+ : categoryResolution(catalog, query.category ?? 'other', query.channel));
205
200
  const purchase = {
206
201
  merchant: merchant.input,
207
202
  amount: query.amount,
@@ -257,26 +252,19 @@ export function exploreCards(catalog, query, options) {
257
252
  }
258
253
  function exploreAssumptions(catalog, merchant, ranked, options) {
259
254
  const assumptions = [];
260
- if (merchant.source === 'caller') {
255
+ if (merchant.source === 'category') {
261
256
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
262
257
  }
263
258
  else if (merchant.source !== 'none') {
264
- const code = merchant.likelyMcc === null ? '' : ` (MCC ${String(merchant.likelyMcc)})`;
265
- assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
259
+ assumptions.push(codesAs(catalog, merchant));
266
260
  }
267
261
  assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
268
262
  assumptions.push(options.memberships.length === 0
269
263
  ? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
270
264
  : `Assumes these memberships: ${options.memberships.join(', ')}.`);
271
- const values = new Map();
272
- for (const e of ranked.slice(0, 10)) {
273
- if (e.currency !== 'cash')
274
- values.set(e.currency, e.centsPerUnit);
275
- }
276
- if (values.size > 0) {
277
- const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
278
- assumptions.push(`Values points at: ${list.join(', ')}.`);
279
- }
265
+ const values = pointValuesText(catalog, ranked.slice(0, 10));
266
+ if (values !== null)
267
+ assumptions.push(values);
280
268
  return assumptions;
281
269
  }
282
270
  function exploreWarnings(catalog, merchant) {
@@ -1,14 +1,7 @@
1
+ import { bonusDollars } from "./bonus.js";
2
+ import { matchesFilters } from "./explore.js";
3
+ import { roundCents } from "./text.js";
1
4
  import { cardValuation } from "./valuation.js";
2
- const round = (n) => Math.round(n * 100) / 100;
3
- function bonusValue(card, centsPerUnit, points, dollars) {
4
- if (points !== undefined)
5
- return (points * centsPerUnit) / 100;
6
- if (dollars === undefined)
7
- return 0;
8
- // A cash back card's bonus is paid like its rewards, so it pools when they
9
- // do; a points card's dollar bonus is a statement credit.
10
- return card.currency === 'cash' ? dollars * centsPerUnit : dollars;
11
- }
12
5
  export function compareOffers(catalog, wallet, filters, today) {
13
6
  const held = new Set(wallet.cards.map((c) => c.id));
14
7
  const offers = [];
@@ -16,16 +9,9 @@ export function compareOffers(catalog, wallet, filters, today) {
16
9
  for (const card of catalog.cards) {
17
10
  if (card.network === 'store')
18
11
  continue;
19
- if (card.business !== filters.business)
20
- continue;
21
12
  if (!filters.includeHeld && held.has(card.id))
22
13
  continue;
23
- const isCash = card.currency === 'cash';
24
- if (filters.kind === 'cash' && !isCash)
25
- continue;
26
- if (filters.kind === 'points' && isCash)
27
- continue;
28
- if (filters.noAnnualFee && card.annual_fee !== 0)
14
+ if (!matchesFilters(card, { ...filters, issuer: null, includeClosed: true }))
29
15
  continue;
30
16
  const offer = card.welcome_offer;
31
17
  if (!offer || (offer.valid_to !== undefined && offer.valid_to < today)) {
@@ -33,7 +19,8 @@ export function compareOffers(catalog, wallet, filters, today) {
33
19
  continue;
34
20
  }
35
21
  const valuation = cardValuation(catalog, wallet, card);
36
- const value = bonusValue(card, valuation.centsPerUnit, offer.points, offer.dollars);
22
+ const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
23
+ 0;
37
24
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
38
25
  offers.push({
39
26
  cardId: card.id,
@@ -50,8 +37,8 @@ export function compareOffers(catalog, wallet, filters, today) {
50
37
  note: offer.note ?? null,
51
38
  annualFee: card.annual_fee ?? null,
52
39
  firstYearFee,
53
- bonusValue: round(value),
54
- firstYearValue: round(value - (firstYearFee ?? 0)),
40
+ bonusValue: roundCents(value),
41
+ firstYearValue: roundCents(value - (firstYearFee ?? 0)),
55
42
  held: held.has(card.id),
56
43
  lastVerified: card.last_verified,
57
44
  verifiedBy: card.verified_by,
@@ -29,3 +29,12 @@ export function localToday(now = new Date()) {
29
29
  String(now.getDate()).padStart(2, '0'),
30
30
  ].join('-');
31
31
  }
32
+ // The period a card's picks are filed under in a wallet. Picks that carry
33
+ // over (until_changed) are never compared, so they file under the calendar
34
+ // year. null when the card's picks reset on different schedules.
35
+ export function choicePeriod(card) {
36
+ const periods = [
37
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
38
+ ];
39
+ return periods.length > 1 ? null : (periods[0] ?? 'calendar_year');
40
+ }
@@ -1,8 +1,9 @@
1
1
  import { resolveMerchant, resolveMerchantById } from "../merchants/resolve.js";
2
- import { evaluateCard, purchaseContext, } from "./evaluate.js";
3
2
  import { bonusStatus } from "./bonus.js";
3
+ import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
4
4
  import { daysBetween } from "./periods.js";
5
5
  import { rotationGap } from "./rotation.js";
6
+ import { categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, num, pointValuesText, } from "./text.js";
6
7
  import { cardValuation } from "./valuation.js";
7
8
  const EPSILON = 1e-9;
8
9
  export const STALE_AFTER_DAYS = 180;
@@ -20,36 +21,9 @@ const PERIOD_WORDS = {
20
21
  cardmember_year: 'cardmember-year',
21
22
  };
22
23
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
23
- export const num = (n) => String(Number(n.toFixed(2)));
24
- // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
25
- const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
26
- export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
27
- export function currencyName(catalog, id) {
28
- return catalog.currencies.find((c) => c.id === id)?.name ?? id;
29
- }
30
- export function currencyLabel(catalog, id) {
31
- const name = currencyName(catalog, id);
32
- return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
33
- }
34
24
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
35
25
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
36
26
  const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
37
- function categoryName(catalog, id) {
38
- return catalog.categories.find((c) => c.id === id)?.name ?? id;
39
- }
40
- // Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
41
- // internet service" and "EV charging" keep their acronyms.
42
- export function categoryPhrase(catalog, id) {
43
- const name = categoryName(catalog, id);
44
- return /^[A-Z]{2}/.test(name)
45
- ? name
46
- : name.charAt(0).toLowerCase() + name.slice(1);
47
- }
48
- export function earnText(catalog, rate, currency) {
49
- return currency === 'cash'
50
- ? `${num(rate)}% cash back`
51
- : `${num(rate)}x ${currencyName(catalog, currency)}`;
52
- }
53
27
  function compareEntries(a, b) {
54
28
  const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
55
29
  if (Math.abs(byReturn) > EPSILON)
@@ -71,14 +45,11 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
71
45
  const card = catalog.cards.find((c) => c.id === held.id);
72
46
  if (!card)
73
47
  continue;
74
- if (card.only_at) {
75
- if (merchant.merchantId === null ||
76
- !card.only_at.includes(merchant.merchantId))
48
+ if (!usableAt(card, merchant)) {
49
+ // A store card elsewhere isn't worth mentioning; a network the merchant
50
+ // doesn't take is.
51
+ if (card.only_at || card.network === 'store' || !merchant.accepts)
77
52
  continue;
78
- }
79
- else if (card.network !== 'store' &&
80
- merchant.accepts &&
81
- !merchant.accepts.includes(card.network)) {
82
53
  const takes = merchant.accepts
83
54
  .map((n) => NETWORK_NAMES[n] ?? n)
84
55
  .join(' or ');
@@ -126,7 +97,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
126
97
  const cap = best.cap;
127
98
  if (cap && amount !== null && amount > cap.remaining) {
128
99
  const over = amount - cap.remaining;
129
- lines.push(`${dollars(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${dollars(over)} earns ${earnText(catalog, cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
100
+ 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.`);
130
101
  }
131
102
  if (valuation.pooledFrom !== null) {
132
103
  const partners = card.pools_into?.when_wallet_has ?? [];
@@ -147,7 +118,7 @@ function warningsFor(catalog, entry, purchase) {
147
118
  const { best, nominal } = evaluation;
148
119
  const warnings = [];
149
120
  const capLabel = (e) => e.cap
150
- ? `${dollars(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
121
+ ? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
151
122
  : '';
152
123
  if (best.cap && best.rule) {
153
124
  const { remaining, cap, period } = best.cap;
@@ -155,10 +126,10 @@ function warningsFor(catalog, entry, purchase) {
155
126
  warnings.push(`Add this card's anniversary (MM-DD) to your wallet so its ${capLabel(best)} can be tracked.`);
156
127
  }
157
128
  else if (purchase.amount !== null && purchase.amount > remaining) {
158
- warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${dollars(remaining)} is left for ${period}.`);
129
+ warnings.push(`This purchase crosses the ${capLabel(best)} on "${best.rule.id}": only ${money(remaining)} is left for ${period}.`);
159
130
  }
160
131
  else if (remaining < cap.amount * NEAR_CAP_SHARE) {
161
- warnings.push(`Only ${dollars(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
132
+ warnings.push(`Only ${money(remaining)} left of the ${capLabel(best)} on "${best.rule.id}" for ${period}.`);
162
133
  }
163
134
  }
164
135
  if (nominal.rule &&
@@ -258,15 +229,14 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
258
229
  if (merchant.source === 'none') {
259
230
  assumptions.push(`Treats "${merchant.input}" as an everyday purchase with no bonus category.`);
260
231
  }
232
+ else if (merchant.source === 'category') {
233
+ assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
234
+ }
261
235
  else if (merchant.category === 'other') {
262
236
  assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
263
237
  }
264
238
  else {
265
- const category = catalog.categories.find((c) => c.id === merchant.category);
266
- const mcc = merchant.likelyMcc ??
267
- (category?.mccs.length === 1 ? category.mccs[0] : undefined);
268
- const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
269
- assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
239
+ assumptions.push(codesAs(catalog, merchant));
270
240
  }
271
241
  if (merchant.inference) {
272
242
  assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
@@ -277,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
277
247
  if (purchase.channel === null && merchant.channel === 'online') {
278
248
  assumptions.push('Assumes an online purchase, since you gave a web address.');
279
249
  }
280
- const pointValues = new Map();
281
- for (const e of entries) {
282
- if (e.valuation.currency !== 'cash')
283
- pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
284
- }
285
- if (pointValues.size > 0) {
286
- const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
287
- assumptions.push(`Values points at: ${list.join(', ')}.`);
288
- }
250
+ const values = pointValuesText(catalog, entries.map((e) => e.valuation));
251
+ if (values !== null)
252
+ assumptions.push(values);
289
253
  return assumptions;
290
254
  }
291
255
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -1,6 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
+ import { bonusStatus, countsTowardBonus } from "./bonus.js";
2
3
  import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- const roundCents = (n) => Math.round(n * 100) / 100;
4
+ import { roundCents } from "./text.js";
4
5
  // Books the spend against the cap of the highest-rate rule that matches, even
5
6
  // when that cap is already used up, so usage stays accurate across a reset.
6
7
  // Returns null when there's nothing to track: no cap, or a cardmember-year cap
@@ -49,7 +50,36 @@ export function applyBooking(wallet, booking) {
49
50
  : c),
50
51
  };
51
52
  }
52
- export function recordPurchase(catalog, wallet, purchase) {
53
- const booking = capBooking(catalog, wallet, purchase);
54
- return booking ? applyBooking(wallet, booking) : wallet;
53
+ // The purchase's progress toward the card's open welcome bonus, if it counts.
54
+ function bonusBooking(catalog, wallet, purchase) {
55
+ const held = wallet.cards.find((c) => c.id === purchase.cardId);
56
+ const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
57
+ if (!status ||
58
+ status.spend === null ||
59
+ (status.remaining ?? 0) <= 0 ||
60
+ !countsTowardBonus(status, purchase.date))
61
+ return null;
62
+ return {
63
+ cardId: purchase.cardId,
64
+ before: status.spent,
65
+ after: roundCents(status.spent + purchase.amount),
66
+ spend: status.spend,
67
+ deadline: status.deadline,
68
+ };
69
+ }
70
+ export function applyBonusBooking(wallet, b) {
71
+ return {
72
+ ...wallet,
73
+ cards: wallet.cards.map((c) => c.id === b.cardId && c.bonus
74
+ ? { ...c, bonus: { ...c.bonus, spent: b.after } }
75
+ : c),
76
+ };
77
+ }
78
+ export function bookPurchase(catalog, wallet, purchase, merchant) {
79
+ const cap = capBooking(catalog, wallet, purchase, merchant);
80
+ const bonus = bonusBooking(catalog, wallet, purchase);
81
+ let next = cap ? applyBooking(wallet, cap) : wallet;
82
+ if (bonus)
83
+ next = applyBonusBooking(next, bonus);
84
+ return { wallet: next, cap, bonus };
55
85
  }
@@ -1,15 +1,15 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
- import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { categoryResolution, choicePeriod, idealHolding, matchesFilters, } from "./explore.js";
4
- import { periodKey } from "./periods.js";
2
+ import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
3
+ import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
4
+ import { choicePeriod, periodKey } from "./periods.js";
5
5
  import { applyBooking, capBooking } from "./record.js";
6
6
  import { rotationGap } from "./rotation.js";
7
+ import { roundCents } from "./text.js";
7
8
  import { cardValuation } from "./valuation.js";
8
9
  const EPSILON = 1e-9;
9
10
  const MONTHS = 12;
10
11
  const SHORTLIST = 8; // best single cards considered for pairs
11
12
  const PER_LINE = 2; // plus the best cards for each spending line
12
- const round = (n) => Math.round(n * 100) / 100;
13
13
  export function resolveSpendLines(catalog, lines) {
14
14
  return lines
15
15
  .filter((l) => l.monthly > 0)
@@ -37,17 +37,10 @@ function withCurrentPicks(card, held, date) {
37
37
  ...held,
38
38
  chosen: {
39
39
  ...held.chosen,
40
- period: periodKey(choicePeriod(card), date, null) ?? '',
40
+ period: periodKey(choicePeriod(card) ?? 'calendar_year', date, null) ?? '',
41
41
  },
42
42
  };
43
43
  }
44
- function usableAt(card, merchant) {
45
- if (card.only_at)
46
- return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
47
- return (card.network === 'store' ||
48
- merchant.accepts === null ||
49
- merchant.accepts.includes(card.network));
50
- }
51
44
  // Spends `lines` every month for a year. Each dollar goes to the card that
52
45
  // earns the most on it right then, so spend moves to another card once a
53
46
  // cap runs out.
@@ -119,13 +112,13 @@ export function simulateWallet(catalog, wallet, lines, start) {
119
112
  };
120
113
  return {
121
114
  label: line.label,
122
- spend: round(r.spend),
123
- earned: round(r.earned),
115
+ spend: roundCents(r.spend),
116
+ earned: roundCents(r.earned),
124
117
  returnPct: r.spend > 0 ? (r.earned / r.spend) * 100 : 0,
125
118
  cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
126
119
  };
127
120
  });
128
- const rewards = round(lineResults.reduce((sum, l) => sum + l.earned, 0));
121
+ const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
129
122
  const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
130
123
  const notes = [];
131
124
  const dates = monthDates(start);
@@ -146,10 +139,10 @@ export function simulateWallet(catalog, wallet, lines, start) {
146
139
  cardId: h.id,
147
140
  picks: h.chosen?.picks ?? {},
148
141
  })),
149
- spend: round(lineResults.reduce((sum, l) => sum + l.spend, 0)),
142
+ spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
150
143
  rewards,
151
144
  annualFees,
152
- net: round(rewards - annualFees),
145
+ net: roundCents(rewards - annualFees),
153
146
  lines: lineResults,
154
147
  notes,
155
148
  };
@@ -293,17 +286,38 @@ export function simulateAdditions(catalog, wallet, lines, options) {
293
286
  memberships: wallet.memberships,
294
287
  valuations: wallet.valuations,
295
288
  });
296
- const extraRewards = round(simulation.rewards - current.rewards);
289
+ const extraRewards = roundCents(simulation.rewards - current.rewards);
297
290
  const annualFee = card.annual_fee ?? 0;
298
291
  additions.push({
299
292
  cardId: card.id,
300
293
  picks: simulation.cards.at(-1)?.picks ?? {},
301
294
  extraRewards,
302
295
  annualFee,
303
- gain: round(extraRewards - annualFee),
296
+ gain: roundCents(extraRewards - annualFee),
304
297
  simulation,
305
298
  });
306
299
  }
307
300
  additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
308
301
  return { current, additions };
309
302
  }
303
+ // Rough monthly amounts for a U.S. household: the starting point when someone
304
+ // hasn't said what they spend. Answers built on them say so.
305
+ export const TYPICAL_MONTHLY_SPEND = [
306
+ { what: 'Groceries', monthly: 500 },
307
+ { what: 'Dining', monthly: 250 },
308
+ { what: 'Gas', monthly: 150 },
309
+ { what: 'Amazon', monthly: 100 },
310
+ { what: 'Streaming', monthly: 40 },
311
+ { what: 'Travel', monthly: 150 },
312
+ { what: 'Everything else', monthly: 800 },
313
+ ];
314
+ // A spending line from everyday words: a category when one matches
315
+ // ("groceries"), otherwise a merchant ("Amazon").
316
+ export function spendLineFor(catalog, what, monthly) {
317
+ const category = findCategory(catalog, what);
318
+ return { merchant: category === null ? what : null, category, monthly };
319
+ }
320
+ export function typicalSpendLines(catalog) {
321
+ return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
322
+ }
323
+ export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} $${String(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
@@ -0,0 +1,53 @@
1
+ import { singleMcc } from "./evaluate.js";
2
+ export const num = (n) => String(Number(n.toFixed(2)));
3
+ export const pct = (n) => `${num(n)}%`;
4
+ export const roundCents = (n) => Math.round(n * 100) / 100;
5
+ // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
6
+ export const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
7
+ export function feeText(fees) {
8
+ const { annualFee, firstYearFee } = fees;
9
+ if (annualFee === null)
10
+ return 'annual fee not on file';
11
+ if (annualFee === 0)
12
+ return 'no annual fee';
13
+ if (firstYearFee !== null && firstYearFee !== annualFee)
14
+ return `${money(annualFee)} annual fee (${firstYearFee === 0 ? 'waived' : money(firstYearFee)} the first year)`;
15
+ return `${money(annualFee)} annual fee`;
16
+ }
17
+ export function currencyName(catalog, id) {
18
+ return catalog.currencies.find((c) => c.id === id)?.name ?? id;
19
+ }
20
+ export function currencyLabel(catalog, id) {
21
+ const name = currencyName(catalog, id);
22
+ return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
23
+ }
24
+ // Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
25
+ // internet service" and "EV charging" keep their acronyms.
26
+ export function categoryPhrase(catalog, id) {
27
+ const name = catalog.categories.find((c) => c.id === id)?.name ?? id;
28
+ return /^[A-Z]{2}/.test(name)
29
+ ? name
30
+ : name.charAt(0).toLowerCase() + name.slice(1);
31
+ }
32
+ export function earnText(catalog, rate, currency) {
33
+ return currency === 'cash'
34
+ ? `${num(rate)}% cash back`
35
+ : `${num(rate)}x ${currencyName(catalog, currency)}`;
36
+ }
37
+ export function codesAs(catalog, merchant) {
38
+ const mcc = merchant.likelyMcc ?? singleMcc(catalog, merchant.category);
39
+ const code = mcc === null ? '' : ` (MCC ${String(mcc)})`;
40
+ return `Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`;
41
+ }
42
+ // "Values points at: Membership Rewards (MR) at 1¢." for the currencies an
43
+ // answer ranks, or null when every card shown earns cash.
44
+ export function pointValuesText(catalog, shown) {
45
+ const values = new Map();
46
+ for (const e of shown)
47
+ if (e.currency !== 'cash')
48
+ values.set(e.currency, e.centsPerUnit);
49
+ if (values.size === 0)
50
+ return null;
51
+ const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
52
+ return `Values points at: ${list.join(', ')}.`;
53
+ }
@@ -0,0 +1,26 @@
1
+ import { addToWallet, withMemberships, } from "./wallet-change.js";
2
+ export const EMPTY_WALLET = {
3
+ valuations: {},
4
+ memberships: [],
5
+ cards: [],
6
+ };
7
+ // A wallet for one answer, from cards the user named in the conversation.
8
+ // Nothing is saved; the same rules as adding cards to a saved wallet apply.
9
+ export function walletFromCards(catalog, named, today) {
10
+ let wallet = null;
11
+ for (const input of named.cards) {
12
+ const change = addToWallet(catalog, wallet, input, today);
13
+ if (!change.ok)
14
+ return change.error;
15
+ wallet = change.wallet;
16
+ }
17
+ if (!wallet)
18
+ return 'Name at least one card.';
19
+ if (named.memberships && named.memberships.length > 0) {
20
+ const change = withMemberships(catalog, wallet, named.memberships);
21
+ if (!change.ok)
22
+ return change.error;
23
+ wallet = change.wallet;
24
+ }
25
+ return wallet;
26
+ }
@@ -1,4 +1,4 @@
1
- export function currencyValuation(catalog, wallet, currencyId) {
1
+ function currencyValuation(catalog, wallet, currencyId) {
2
2
  const currency = catalog.currencies.find((c) => c.id === currencyId);
3
3
  if (!currency)
4
4
  throw new Error(`unknown currency '${currencyId}'`);
@@ -1,8 +1,8 @@
1
- import { periodKey } from "./periods.js";
1
+ import { choicePeriod, periodKey } from "./periods.js";
2
2
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
3
3
  const failed = (error) => ({ ok: false, error });
4
4
  const list = (ids) => ids.join(', ');
5
- export function findCatalogCard(catalog, id, today) {
5
+ function findCatalogCard(catalog, id, today) {
6
6
  const card = catalog.cards.find((c) => c.id === id);
7
7
  if (card)
8
8
  return card;
@@ -11,22 +11,16 @@ export function findCatalogCard(catalog, id, today) {
11
11
  ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
12
12
  : `No card '${id}' in earnrate. Search the catalog to find its id.`;
13
13
  }
14
- export function isAnniversary(text) {
14
+ function isAnniversary(text) {
15
15
  const m = /^(\d{2})-(\d{2})$/.exec(text);
16
16
  if (!m)
17
17
  return false;
18
18
  const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
19
19
  return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
20
20
  }
21
- // The period key picks are filed under: the card's choice period, or for
22
- // picks that carry over, the current year (it's never compared).
23
- function pickPeriod(card, today) {
24
- const periods = [
25
- ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
26
- ];
27
- if (periods.length > 1)
28
- return null;
29
- return periodKey(periods[0] ?? 'calendar_year', today, null);
21
+ function pickKey(card, today) {
22
+ const period = choicePeriod(card);
23
+ return period === null ? null : periodKey(period, today, null);
30
24
  }
31
25
  export function cardFields(card, held, input, today) {
32
26
  const problems = [];
@@ -77,7 +71,7 @@ export function cardFields(card, held, input, today) {
77
71
  problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
78
72
  }
79
73
  }
80
- const period = pickPeriod(card, today);
74
+ const period = pickKey(card, today);
81
75
  if (period === null) {
82
76
  problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
83
77
  }
@@ -89,21 +83,48 @@ export function cardFields(card, held, input, today) {
89
83
  };
90
84
  }
91
85
  }
86
+ if (input.bonus === null)
87
+ fields.bonus = null;
88
+ else if (input.bonus !== undefined) {
89
+ const b = input.bonus;
90
+ const isDate = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d) &&
91
+ new Date(`${d}T00:00:00Z`).toISOString().startsWith(d);
92
+ if (!isDate(b.opened))
93
+ problems.push(`"${b.opened}" isn't a date. Use YYYY-MM-DD.`);
94
+ else if (b.opened > today)
95
+ problems.push("The account's opening date is in the future.");
96
+ if (b.spent !== undefined && !(b.spent >= 0))
97
+ problems.push('Spending toward the bonus must be $0 or more.');
98
+ if (b.spend !== undefined && !(b.spend > 0))
99
+ problems.push('The spending needed must be more than $0.');
100
+ if (b.by !== undefined && (!isDate(b.by) || b.by < b.opened))
101
+ problems.push(`"${b.by}" isn't a deadline after the opening date.`);
102
+ if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
103
+ problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
104
+ fields.bonus = {
105
+ opened: b.opened,
106
+ spent: b.spent ?? held?.bonus?.spent ?? 0,
107
+ ...(b.spend === undefined ? {} : { spend: b.spend }),
108
+ ...(b.by === undefined ? {} : { by: b.by }),
109
+ };
110
+ }
92
111
  return problems.length > 0 ? problems.join('\n') : fields;
93
112
  }
94
- export const newHolding = (id) => ({
113
+ const newHolding = (id) => ({
95
114
  id,
96
115
  cap_usage: {},
97
116
  activated: [],
98
117
  });
99
- export const withFields = (held, f) => ({
118
+ const withFields = ({ bonus, ...held }, f) => ({
100
119
  ...held,
120
+ ...(bonus && f.bonus === undefined ? { bonus } : {}),
101
121
  ...(f.checkout_label === undefined
102
122
  ? {}
103
123
  : { checkout_label: f.checkout_label }),
104
124
  ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
105
125
  ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
106
126
  ...(f.activated === undefined ? {} : { activated: f.activated }),
127
+ ...(f.bonus ? { bonus: f.bonus } : {}),
107
128
  });
108
129
  // `wallet` is null when there isn't one yet; adding a card starts it.
109
130
  export function addToWallet(catalog, wallet, input, today) {