earnrate 0.2.0 → 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 (47) hide show
  1. package/README.md +22 -20
  2. package/dist/brand.js +2 -3
  3. package/dist/cli/args.js +64 -51
  4. package/dist/cli/cheatsheet.js +9 -12
  5. package/dist/cli/explore.js +28 -27
  6. package/dist/cli/import.js +3 -8
  7. package/dist/cli/offers.js +7 -6
  8. package/dist/cli/run.js +17 -13
  9. package/dist/cli/wallet.js +7 -6
  10. package/dist/data/file-store.js +54 -15
  11. package/dist/data/load.js +1 -1
  12. package/dist/data/store.js +0 -16
  13. package/dist/data/wallet-edit.js +68 -2
  14. package/dist/engine/bonus.js +15 -12
  15. package/dist/engine/cheat-sheet.js +2 -2
  16. package/dist/engine/evaluate.js +34 -10
  17. package/dist/engine/explore.js +9 -22
  18. package/dist/engine/offers.js +8 -21
  19. package/dist/engine/periods.js +9 -0
  20. package/dist/engine/recommend.js +14 -53
  21. package/dist/engine/record.js +2 -7
  22. package/dist/engine/simulate.js +12 -19
  23. package/dist/engine/text.js +53 -0
  24. package/dist/engine/transient.js +5 -0
  25. package/dist/engine/valuation.js +1 -1
  26. package/dist/engine/wallet-change.js +9 -15
  27. package/dist/engine/wallet-summary.js +4 -8
  28. package/dist/format/card-text.js +79 -0
  29. package/dist/format/cheat-sheet.js +1 -1
  30. package/dist/format/explore.js +3 -13
  31. package/dist/format/offers.js +5 -14
  32. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  33. package/dist/format/wallet.js +38 -0
  34. package/dist/import/analyze.js +17 -19
  35. package/dist/mcp/context.js +92 -0
  36. package/dist/mcp/inputs.js +148 -0
  37. package/dist/mcp/schemas.js +1 -1
  38. package/dist/mcp/server.js +14 -818
  39. package/dist/mcp/tools/account.js +71 -0
  40. package/dist/mcp/tools/answers.js +329 -0
  41. package/dist/mcp/tools/wallet.js +128 -0
  42. package/dist/merchants/from-env.js +1 -1
  43. package/dist/merchants/resolve.js +3 -3
  44. package/package.json +2 -2
  45. package/dist/data/wallet-setup.js +0 -60
  46. package/dist/data/wallet-write.js +0 -140
  47. /package/dist/{cli → format}/cards.js +0 -0
@@ -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 &&
@@ -265,11 +236,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
265
236
  assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
266
237
  }
267
238
  else {
268
- const category = catalog.categories.find((c) => c.id === merchant.category);
269
- const mcc = merchant.likelyMcc ??
270
- (category?.mccs.length === 1 ? category.mccs[0] : undefined);
271
- const code = mcc === undefined ? '' : ` (MCC ${String(mcc)})`;
272
- assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
239
+ assumptions.push(codesAs(catalog, merchant));
273
240
  }
274
241
  if (merchant.inference) {
275
242
  assumptions.push(`Claude's reasoning: ${merchant.inference.rationale}`);
@@ -280,15 +247,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
280
247
  if (purchase.channel === null && merchant.channel === 'online') {
281
248
  assumptions.push('Assumes an online purchase, since you gave a web address.');
282
249
  }
283
- const pointValues = new Map();
284
- for (const e of entries) {
285
- if (e.valuation.currency !== 'cash')
286
- pointValues.set(e.valuation.currency, e.valuation.centsPerUnit);
287
- }
288
- if (pointValues.size > 0) {
289
- const list = [...pointValues].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
290
- assumptions.push(`Values points at: ${list.join(', ')}.`);
291
- }
250
+ const values = pointValuesText(catalog, entries.map((e) => e.valuation));
251
+ if (values !== null)
252
+ assumptions.push(values);
292
253
  return assumptions;
293
254
  }
294
255
  export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
@@ -1,7 +1,7 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { bonusStatus, countsTowardBonus } from "./bonus.js";
3
3
  import { evaluateCard, purchaseContext } from "./evaluate.js";
4
- const roundCents = (n) => Math.round(n * 100) / 100;
4
+ import { roundCents } from "./text.js";
5
5
  // Books the spend against the cap of the highest-rate rule that matches, even
6
6
  // when that cap is already used up, so usage stays accurate across a reset.
7
7
  // Returns null when there's nothing to track: no cap, or a cardmember-year cap
@@ -50,12 +50,8 @@ export function applyBooking(wallet, booking) {
50
50
  : c),
51
51
  };
52
52
  }
53
- export function recordPurchase(catalog, wallet, purchase) {
54
- const booking = capBooking(catalog, wallet, purchase);
55
- return booking ? applyBooking(wallet, booking) : wallet;
56
- }
57
53
  // The purchase's progress toward the card's open welcome bonus, if it counts.
58
- export function bonusBooking(catalog, wallet, purchase) {
54
+ function bonusBooking(catalog, wallet, purchase) {
59
55
  const held = wallet.cards.find((c) => c.id === purchase.cardId);
60
56
  const status = held ? bonusStatus(catalog, wallet, held, purchase.date) : null;
61
57
  if (!status ||
@@ -79,7 +75,6 @@ export function applyBonusBooking(wallet, b) {
79
75
  : c),
80
76
  };
81
77
  }
82
- // Everything recording a purchase changes: its cap and its welcome bonus.
83
78
  export function bookPurchase(catalog, wallet, purchase, merchant) {
84
79
  const cap = capBooking(catalog, wallet, purchase, merchant);
85
80
  const bonus = bonusBooking(catalog, wallet, purchase);
@@ -1,15 +1,15 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
- import { evaluateCard, purchaseContext } from "./evaluate.js";
3
- import { categoryResolution, choicePeriod, findCategory, 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,14 +286,14 @@ 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
  }
@@ -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
+ }
@@ -1,4 +1,9 @@
1
1
  import { addToWallet, withMemberships, } from "./wallet-change.js";
2
+ export const EMPTY_WALLET = {
3
+ valuations: {},
4
+ memberships: [],
5
+ cards: [],
6
+ };
2
7
  // A wallet for one answer, from cards the user named in the conversation.
3
8
  // Nothing is saved; the same rules as adding cards to a saved wallet apply.
4
9
  export function walletFromCards(catalog, named, today) {
@@ -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
  }
@@ -116,12 +110,12 @@ export function cardFields(card, held, input, today) {
116
110
  }
117
111
  return problems.length > 0 ? problems.join('\n') : fields;
118
112
  }
119
- export const newHolding = (id) => ({
113
+ const newHolding = (id) => ({
120
114
  id,
121
115
  cap_usage: {},
122
116
  activated: [],
123
117
  });
124
- export const withFields = ({ bonus, ...held }, f) => ({
118
+ const withFields = ({ bonus, ...held }, f) => ({
125
119
  ...held,
126
120
  ...(bonus && f.bonus === undefined ? { bonus } : {}),
127
121
  ...(f.checkout_label === undefined
@@ -1,8 +1,8 @@
1
1
  import { bonusStatus } from "./bonus.js";
2
- import { pickedCategories } from "./evaluate.js";
3
- import { daysBetween, periodKey } from "./periods.js";
4
- import { rotationGap } from "./rotation.js";
2
+ import { capUsed, pickedCategories } from "./evaluate.js";
3
+ import { daysBetween } from "./periods.js";
5
4
  import { STALE_AFTER_DAYS } from "./recommend.js";
5
+ import { rotationGap } from "./rotation.js";
6
6
  import { cardValuation } from "./valuation.js";
7
7
  function capSummaries(card, held, today) {
8
8
  const caps = new Map();
@@ -19,11 +19,7 @@ function capSummaries(card, held, today) {
19
19
  }
20
20
  }
21
21
  return [...caps].map(([key, { cap, rules }]) => {
22
- const currentPeriod = periodKey(cap.period, today, held.anniversary ?? null);
23
- const usage = held.cap_usage[key];
24
- const spent = currentPeriod !== null && usage?.period === currentPeriod
25
- ? usage.spent
26
- : 0;
22
+ const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
27
23
  return {
28
24
  key,
29
25
  rules,
@@ -0,0 +1,79 @@
1
+ import { earnText } from "../engine/text.js";
2
+ // Card names, fees and requirements as earnrate.dev and its static search
3
+ // pages show them. Node-free: the site bundles it.
4
+ const ISSUERS = {
5
+ amex: 'American Express',
6
+ 'bank-of-america': 'Bank of America',
7
+ 'capital-one': 'Capital One',
8
+ chase: 'Chase',
9
+ citi: 'Citi',
10
+ column: 'Bilt',
11
+ sofi: 'SoFi',
12
+ 'td-bank': 'TD Bank',
13
+ usbank: 'U.S. Bank',
14
+ 'wells-fargo': 'Wells Fargo',
15
+ };
16
+ export const MEMBERSHIPS = {
17
+ 'amazon-prime': 'Amazon Prime',
18
+ 'bank-of-america-account': 'Bank of America checking or savings',
19
+ 'bofa-rewards-member': 'BofA Rewards Member',
20
+ 'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
21
+ 'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
22
+ 'bofa-rewards-premier': 'BofA Rewards Premier',
23
+ };
24
+ export const PICK_PERIODS = {
25
+ month: 'each month',
26
+ quarter: 'each quarter',
27
+ calendar_year: 'each year',
28
+ until_changed: 'until you change it',
29
+ };
30
+ export const CAP_PERIODS = {
31
+ month: 'a month',
32
+ quarter: 'a quarter',
33
+ calendar_year: 'a year',
34
+ cardmember_year: 'a card year',
35
+ };
36
+ export const words = (id) => id.replace(/[-_.]/g, ' ');
37
+ // Whole dollars, for fees and caps.
38
+ export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
39
+ export const issuerName = (id) => ISSUERS[id] ?? id;
40
+ export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
41
+ export function cardText(catalog) {
42
+ const cards = new Map(catalog.cards.map((c) => [c.id, c]));
43
+ function shortName(id) {
44
+ const name = cards.get(id)?.name ?? id;
45
+ return name
46
+ .replace(/^American Express /, 'Amex ')
47
+ .replace(/^The Platinum Card from American Express$/, 'Amex Platinum')
48
+ .replace(/ (Visa Signature |Visa Infinite )?(Credit )?Card$/, '')
49
+ .replace(/ Card by /, ' by ');
50
+ }
51
+ function feeLabel(id) {
52
+ const card = cards.get(id);
53
+ const fee = card?.annual_fee;
54
+ if (fee === undefined)
55
+ return 'Annual fee not on file';
56
+ if (fee === 0)
57
+ return 'No annual fee';
58
+ const first = card?.first_year_fee;
59
+ if (first !== undefined && first !== fee)
60
+ return `${usd(fee)} a year (${first === 0 ? 'waived' : usd(first)} the first year)`;
61
+ return `${usd(fee)} a year`;
62
+ }
63
+ // `until` is the last day a time-limited rate applies, already formatted.
64
+ function requirementLines(cardId, r, until = null) {
65
+ const lines = [];
66
+ if (r.pick)
67
+ lines.push(`Pick ${r.pick.options.map(words).join(' or ')} as a bonus category (${PICK_PERIODS[r.pick.period]})`);
68
+ if (r.activation !== null)
69
+ lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
70
+ if (r.membership !== null)
71
+ lines.push(`With ${membershipName(r.membership)}`);
72
+ if (until !== null)
73
+ lines.push(`This rate runs through ${until}`);
74
+ if (r.cap)
75
+ lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period] ?? ''}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
76
+ return lines;
77
+ }
78
+ return { cards, shortName, feeLabel, requirementLines };
79
+ }
@@ -1,4 +1,4 @@
1
- const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
1
+ import { pct } from "../engine/text.js";
2
2
  export function formatCheatSheet(sheet) {
3
3
  const labelWidth = Math.max(...sheet.rows.map((r) => r.label.length));
4
4
  const cardWidth = Math.max(...sheet.rows.map((r) => (r.cardName ?? 'none of your cards').length));
@@ -1,15 +1,5 @@
1
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
2
- const wholeUsd = (n) => usd(Math.round(n));
3
- const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
4
- function feeText(e) {
5
- if (e.annualFee === null)
6
- return 'annual fee not on file';
7
- if (e.annualFee === 0)
8
- return 'no annual fee';
9
- if (e.firstYearFee !== null && e.firstYearFee !== e.annualFee)
10
- return `${usd(e.annualFee)} annual fee (${e.firstYearFee === 0 ? 'waived' : usd(e.firstYearFee)} the first year)`;
11
- return `${usd(e.annualFee)} annual fee`;
12
- }
1
+ import { feeText, money, pct } from "../engine/text.js";
2
+ const wholeUsd = (n) => money(Math.round(n));
13
3
  const TOP_BEST = 10;
14
4
  export function formatExploration(r, all) {
15
5
  const m = r.merchant;
@@ -24,7 +14,7 @@ export function formatExploration(r, all) {
24
14
  const width = Math.max(...shown.map((e) => e.cardName.length));
25
15
  const lines = [`Best cards for ${title}:`, ''];
26
16
  shown.forEach((e, i) => {
27
- const earned = e.earned === null ? '' : ` ${usd(e.earned)}`;
17
+ const earned = e.earned === null ? '' : ` ${money(e.earned)}`;
28
18
  lines.push(`${String(i + 1).padStart(2)}. ${e.cardName.padEnd(width)} ${pct(e.effectiveReturnPct).padStart(5)}${earned} ${feeText(e)}`);
29
19
  for (const c of e.conditions)
30
20
  lines.push(` ${c}`);
@@ -1,19 +1,10 @@
1
+ import { feeText, money } from "../engine/text.js";
1
2
  const TOP = 10;
2
- const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
3
3
  function reward(o, currencyName) {
4
4
  if (o.dollars !== null)
5
- return usd(o.dollars);
5
+ return money(o.dollars);
6
6
  return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
7
  }
8
- function feeText(o) {
9
- if (o.annualFee === null)
10
- return 'annual fee not on file';
11
- if (o.annualFee === 0)
12
- return 'no annual fee';
13
- if (o.firstYearFee !== null && o.firstYearFee !== o.annualFee)
14
- return `${usd(o.annualFee)} annual fee (${o.firstYearFee === 0 ? 'waived' : usd(o.firstYearFee)} the first year)`;
15
- return `${usd(o.annualFee)} annual fee`;
16
- }
17
8
  export function formatOffers(c, currencyName, all) {
18
9
  if (c.offers.length === 0) {
19
10
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -23,11 +14,11 @@ export function formatOffers(c, currencyName, all) {
23
14
  const lines = [];
24
15
  shown.forEach((o, i) => {
25
16
  const weeks = (o.months * 52) / 12;
26
- const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
17
+ const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
27
18
  const spend = o.spend > 0
28
- ? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
19
+ ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
29
20
  : 'with no spending required';
30
- 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.' : ''}`);
21
+ lines.push(`${String(i + 1).padStart(2)}. ${o.cardName.padEnd(width)} about ${money(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.' : ''}`);
31
22
  if (o.validTo)
32
23
  lines.push(` Offer ends ${o.validTo}.`);
33
24
  if (o.note)
@@ -1,11 +1,7 @@
1
+ import { money, pct } from "../engine/text.js";
1
2
  export const plain = (_format, text) => text;
2
3
  const TOP = 3;
3
- const pct = (n) => `${String(Number(n.toFixed(2)))}%`;
4
4
  const cents = (n) => `$${n.toFixed(2)}`;
5
- const amountText = (n) => `$${n.toLocaleString('en-US', {
6
- minimumFractionDigits: Number.isInteger(n) ? 0 : 2,
7
- maximumFractionDigits: 2,
8
- })}`;
9
5
  const label = (c) => c.checkoutLabel ?? c.cardName;
10
6
  function header(rec, purchase) {
11
7
  const m = rec.merchant;
@@ -23,7 +19,7 @@ function header(rec, purchase) {
23
19
  : m.resolvedName,
24
20
  ];
25
21
  if (purchase.amount !== null)
26
- parts.push(amountText(purchase.amount));
22
+ parts.push(money(purchase.amount));
27
23
  if (m.channel)
28
24
  parts.push(m.channel === 'online' ? 'online' : 'in-store');
29
25
  return parts.join(' · ');
@@ -0,0 +1,38 @@
1
+ import { money } from "../engine/text.js";
2
+ export function formatWalletSummary(summary) {
3
+ return summary.cards
4
+ .map((c) => {
5
+ const caps = c.caps
6
+ .filter((cap) => cap.remaining !== null)
7
+ .map((cap) => `${cap.key} ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
8
+ const chosen = c.chosen
9
+ ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
10
+ : '';
11
+ const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
12
+ const rotation = c.rotationGap === null
13
+ ? ''
14
+ : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
15
+ const b = c.bonus;
16
+ const bonus = b?.state === 'open' && b.spend !== null
17
+ ? `; welcome bonus ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}`
18
+ : b
19
+ ? `; welcome bonus ${b.state}`
20
+ : '';
21
+ return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
22
+ })
23
+ .join('\n');
24
+ }
25
+ export function formatBooking(amount, cardId, cap, bonus) {
26
+ const messages = [];
27
+ if (cap) {
28
+ const left = Math.max(0, cap.capAmount - cap.after);
29
+ messages.push(`Recorded ${money(amount)} on ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} for ${cap.period}, ${money(left)} left.`);
30
+ }
31
+ if (bonus) {
32
+ const left = Math.max(0, bonus.spend - bonus.after);
33
+ messages.push(left === 0
34
+ ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
35
+ : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
36
+ }
37
+ return messages.join(' ');
38
+ }