earnrate 0.2.0 → 0.2.2

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 (64) hide show
  1. package/README.md +23 -20
  2. package/data/cards/amex-blue-business-plus.yaml +1 -0
  3. package/data/cards/amex-blue-cash-everyday.yaml +4 -3
  4. package/data/cards/amex-blue-cash-preferred.yaml +5 -7
  5. package/data/cards/amex-gold.yaml +1 -0
  6. package/data/cards/amex-platinum.yaml +1 -0
  7. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +6 -6
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +6 -6
  9. package/data/cards/chase-freedom-flex.yaml +5 -1
  10. package/data/cards/chase-southwest-plus.yaml +5 -5
  11. package/data/cards/chase-southwest-premier-business.yaml +8 -5
  12. package/data/cards/chase-southwest-premier.yaml +6 -6
  13. package/data/cards/chase-southwest-priority.yaml +6 -6
  14. package/data/cards/citi-aadvantage-business.yaml +6 -6
  15. package/data/cards/citi-aadvantage-platinum-select.yaml +10 -7
  16. package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -4
  17. package/dist/brand.js +2 -3
  18. package/dist/cli/args.js +64 -51
  19. package/dist/cli/cheatsheet.js +9 -12
  20. package/dist/cli/explore.js +28 -27
  21. package/dist/cli/import.js +3 -8
  22. package/dist/cli/offers.js +7 -6
  23. package/dist/cli/run.js +17 -13
  24. package/dist/cli/wallet.js +7 -6
  25. package/dist/data/file-store.js +54 -15
  26. package/dist/data/load.js +1 -1
  27. package/dist/data/store.js +0 -16
  28. package/dist/data/wallet-edit.js +78 -3
  29. package/dist/engine/bonus.js +15 -12
  30. package/dist/engine/cheat-sheet.js +2 -2
  31. package/dist/engine/evaluate.js +56 -13
  32. package/dist/engine/explore.js +9 -22
  33. package/dist/engine/offers.js +10 -22
  34. package/dist/engine/periods.js +9 -0
  35. package/dist/engine/recommend.js +14 -53
  36. package/dist/engine/record.js +2 -7
  37. package/dist/engine/setup.js +2 -2
  38. package/dist/engine/simulate.js +12 -19
  39. package/dist/engine/text.js +53 -0
  40. package/dist/engine/transient.js +5 -0
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +46 -19
  43. package/dist/engine/wallet-summary.js +4 -8
  44. package/dist/format/card-text.js +79 -0
  45. package/dist/format/cheat-sheet.js +1 -1
  46. package/dist/format/explore.js +3 -13
  47. package/dist/format/offers.js +15 -18
  48. package/dist/{cli/format.js → format/recommendation.js} +2 -6
  49. package/dist/format/wallet.js +57 -0
  50. package/dist/import/analyze.js +17 -19
  51. package/dist/mcp/context.js +92 -0
  52. package/dist/mcp/inputs.js +155 -0
  53. package/dist/mcp/schemas.js +2 -1
  54. package/dist/mcp/server.js +15 -819
  55. package/dist/mcp/tools/account.js +78 -0
  56. package/dist/mcp/tools/answers.js +338 -0
  57. package/dist/mcp/tools/wallet.js +128 -0
  58. package/dist/merchants/from-env.js +1 -1
  59. package/dist/merchants/resolve.js +3 -3
  60. package/dist/schema/card.js +4 -0
  61. package/package.json +3 -2
  62. package/dist/data/wallet-setup.js +0 -60
  63. package/dist/data/wallet-write.js +0 -140
  64. /package/dist/{cli → format}/cards.js +0 -0
@@ -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,9 @@
1
- import { periodKey } from "./periods.js";
1
+ import { capByKey } from "./evaluate.js";
2
+ import { choicePeriod, periodKey } from "./periods.js";
2
3
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
3
4
  const failed = (error) => ({ ok: false, error });
4
5
  const list = (ids) => ids.join(', ');
5
- export function findCatalogCard(catalog, id, today) {
6
+ function findCatalogCard(catalog, id, today) {
6
7
  const card = catalog.cards.find((c) => c.id === id);
7
8
  if (card)
8
9
  return card;
@@ -11,27 +12,23 @@ export function findCatalogCard(catalog, id, today) {
11
12
  ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
12
13
  : `No card '${id}' in earnrate. Search the catalog to find its id.`;
13
14
  }
14
- export function isAnniversary(text) {
15
+ function isAnniversary(text) {
15
16
  const m = /^(\d{2})-(\d{2})$/.exec(text);
16
17
  if (!m)
17
18
  return false;
18
19
  const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
19
20
  return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
20
21
  }
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);
22
+ function pickKey(card, today) {
23
+ const period = choicePeriod(card);
24
+ return period === null ? null : periodKey(period, today, null);
30
25
  }
31
26
  export function cardFields(card, held, input, today) {
32
27
  const problems = [];
33
28
  const fields = {};
34
- if (input.checkoutLabel !== undefined) {
29
+ if (input.checkoutLabel === null)
30
+ fields.checkout_label = null;
31
+ else if (input.checkoutLabel !== undefined) {
35
32
  if (input.checkoutLabel.trim() === '')
36
33
  problems.push('The checkout label is empty.');
37
34
  // earnrate never stores card numbers; a last four is fine.
@@ -77,7 +74,7 @@ export function cardFields(card, held, input, today) {
77
74
  problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
78
75
  }
79
76
  }
80
- const period = pickPeriod(card, today);
77
+ const period = pickKey(card, today);
81
78
  if (period === null) {
82
79
  problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
83
80
  }
@@ -89,6 +86,34 @@ export function cardFields(card, held, input, today) {
89
86
  };
90
87
  }
91
88
  }
89
+ if (input.capSpent !== undefined) {
90
+ const usage = { ...held?.cap_usage };
91
+ const anniversary = fields.anniversary ?? held?.anniversary ?? null;
92
+ for (const [key, spent] of Object.entries(input.capSpent)) {
93
+ const cap = capByKey(card, key);
94
+ if (!cap) {
95
+ const keys = [
96
+ ...card.caps.map((c) => c.id),
97
+ ...card.rules
98
+ .filter((r) => typeof r.cap === 'object')
99
+ .map((r) => r.id),
100
+ ];
101
+ problems.push(keys.length > 0
102
+ ? `${card.id} has no cap '${key}'. Its caps are: ${list(keys)}.`
103
+ : `${card.id} has no spending caps.`);
104
+ continue;
105
+ }
106
+ if (!(spent >= 0))
107
+ problems.push('Spending on a cap must be $0 or more.');
108
+ const period = periodKey(cap.period, today, anniversary);
109
+ if (period === null) {
110
+ problems.push(`The '${key}' cap resets on the card's anniversary; give the anniversary (MM-DD) too.`);
111
+ continue;
112
+ }
113
+ usage[key] = { period, spent };
114
+ }
115
+ fields.cap_usage = usage;
116
+ }
92
117
  if (input.bonus === null)
93
118
  fields.bonus = null;
94
119
  else if (input.bonus !== undefined) {
@@ -116,20 +141,22 @@ export function cardFields(card, held, input, today) {
116
141
  }
117
142
  return problems.length > 0 ? problems.join('\n') : fields;
118
143
  }
119
- export const newHolding = (id) => ({
144
+ const newHolding = (id) => ({
120
145
  id,
121
146
  cap_usage: {},
122
147
  activated: [],
123
148
  });
124
- export const withFields = ({ bonus, ...held }, f) => ({
149
+ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
125
150
  ...held,
126
151
  ...(bonus && f.bonus === undefined ? { bonus } : {}),
127
- ...(f.checkout_label === undefined
128
- ? {}
129
- : { checkout_label: f.checkout_label }),
152
+ ...(checkout_label !== undefined && f.checkout_label === undefined
153
+ ? { checkout_label }
154
+ : {}),
155
+ ...(f.checkout_label ? { checkout_label: f.checkout_label } : {}),
130
156
  ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
131
157
  ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
132
158
  ...(f.activated === undefined ? {} : { activated: f.activated }),
159
+ ...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
133
160
  ...(f.bonus ? { bonus: f.bonus } : {}),
134
161
  });
135
162
  // `wallet` is null when there isn't one yet; adding a card starts it.
@@ -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,17 @@
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
- if (o.dollars !== null)
5
- return usd(o.dollars);
6
- return `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
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`;
4
+ const amount = o.dollars !== null
5
+ ? money(o.dollars)
6
+ : `${(o.points ?? 0).toLocaleString('en-US')} ${currencyName}`;
7
+ return o.varies ? `Up to ${amount}` : amount;
16
8
  }
9
+ const asOf = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
10
+ month: 'short',
11
+ day: 'numeric',
12
+ year: 'numeric',
13
+ timeZone: 'UTC',
14
+ });
17
15
  export function formatOffers(c, currencyName, all) {
18
16
  if (c.offers.length === 0) {
19
17
  return `No published welcome offers match.${c.withoutOffer.length > 0 ? ` ${String(c.withoutOffer.length)} matching card(s) have none on file.` : ''}\n`;
@@ -23,13 +21,12 @@ export function formatOffers(c, currencyName, all) {
23
21
  const lines = [];
24
22
  shown.forEach((o, i) => {
25
23
  const weeks = (o.months * 52) / 12;
26
- const pace = o.spend > 0 ? ` (about ${usd(Math.round(o.spend / weeks))} a week)` : '';
24
+ const pace = o.spend > 0 ? ` (about ${money(Math.round(o.spend / weeks))} a week)` : '';
27
25
  const spend = o.spend > 0
28
- ? `after ${usd(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
26
+ ? `after ${money(o.spend)} in ${String(o.months)} month${o.months === 1 ? '' : 's'}${pace}`
29
27
  : '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.' : ''}`);
31
- if (o.validTo)
32
- lines.push(` Offer ends ${o.validTo}.`);
28
+ 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.' : ''}`);
29
+ lines.push(` ${o.validTo ? `Offer ends ${o.validTo}. ` : ''}Public offer as of ${asOf(o.lastVerified)}; yours may differ.`);
33
30
  if (o.note)
34
31
  lines.push(` ${o.note}`);
35
32
  });
@@ -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,57 @@
1
+ import { money } from "../engine/text.js";
2
+ const NETWORKS = {
3
+ visa: 'Visa',
4
+ mastercard: 'Mastercard',
5
+ amex: 'American Express',
6
+ discover: 'Discover',
7
+ store: 'store card',
8
+ };
9
+ const words = (id) => id.replace(/[-_.]/g, ' ');
10
+ export function formatWalletSummary(summary) {
11
+ const cards = summary.cards.map((c) => {
12
+ const lines = [
13
+ `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
14
+ ];
15
+ if (c.onlyAt)
16
+ lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
17
+ if (c.chosen) {
18
+ const picks = Object.entries(c.chosen.picks)
19
+ .map(([choice, options]) => `${words(choice)}: ${options.map(words).join(', ')}`)
20
+ .join('; ');
21
+ lines.push(c.chosen.current
22
+ ? ` Picks for ${c.chosen.period}: ${picks}. They need choosing again when the period ends.`
23
+ : ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
24
+ }
25
+ if (c.activated.length > 0)
26
+ lines.push(` Activated: ${c.activated.join(', ')}.`);
27
+ if (c.rotationGap !== null)
28
+ lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
29
+ for (const cap of c.caps)
30
+ if (cap.remaining !== null)
31
+ lines.push(` Cap ${cap.key}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}.`);
32
+ const b = c.bonus;
33
+ if (b?.state === 'open' && b.spend !== null)
34
+ lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
35
+ else if (b)
36
+ lines.push(` Welcome bonus: ${b.state}.`);
37
+ return lines.join('\n');
38
+ });
39
+ const memberships = summary.memberships.length > 0
40
+ ? `Memberships: ${summary.memberships.join(', ')}.`
41
+ : 'Memberships: none saved.';
42
+ return [...cards, memberships].join('\n');
43
+ }
44
+ export function formatBooking(amount, cardId, cap, bonus) {
45
+ const messages = [];
46
+ if (cap) {
47
+ const left = Math.max(0, cap.capAmount - cap.after);
48
+ 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.`);
49
+ }
50
+ if (bonus) {
51
+ const left = Math.max(0, bonus.spend - bonus.after);
52
+ messages.push(left === 0
53
+ ? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
54
+ : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
55
+ }
56
+ return messages.join(' ');
57
+ }
@@ -1,11 +1,11 @@
1
1
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
2
- import { categoryMatches } from "../engine/evaluate.js";
2
+ import { capByKey, categoryMatches } from "../engine/evaluate.js";
3
3
  import { periodKey } from "../engine/periods.js";
4
- import { applyBooking, capBooking } from "../engine/record.js";
5
4
  import { recommend } from "../engine/recommend.js";
5
+ import { applyBooking, capBooking } from "../engine/record.js";
6
+ import { roundCents } from "../engine/text.js";
6
7
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
7
8
  import { cleanDescriptor } from "./statement.js";
8
- const round = (n) => Math.round(n * 100) / 100;
9
9
  function resolveLine(catalog, line) {
10
10
  const cleaned = cleanDescriptor(line.description);
11
11
  // "NAME-CHEAP.COM* LV0I6X": the part before the asterisk names the merchant.
@@ -30,12 +30,10 @@ function resolveLine(catalog, line) {
30
30
  function capPeriodKey(catalog, wallet, booking, date) {
31
31
  const card = catalog.cards.find((c) => c.id === booking.cardId);
32
32
  const held = wallet.cards.find((c) => c.id === booking.cardId);
33
- const shared = card?.caps.find((c) => c.id === booking.capKey);
34
- const inline = card?.rules.find((r) => r.id === booking.capKey)?.cap;
35
- const period = shared?.period ?? (typeof inline === 'object' ? inline.period : undefined);
36
- if (!period)
33
+ const cap = card && capByKey(card, booking.capKey);
34
+ if (!cap)
37
35
  return null;
38
- return periodKey(period, date, held?.anniversary ?? null);
36
+ return periodKey(cap.period, date, held?.anniversary ?? null);
39
37
  }
40
38
  // Replays the statements twice: once on the cards actually used, once on
41
39
  // earnrate's picks, each with its own running cap usage, so a cap that fills
@@ -76,7 +74,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
76
74
  if (progress &&
77
75
  countsTowardBonus(progress, line.date) &&
78
76
  !alreadyRecorded(entry, 'bonus')) {
79
- progress.after = round(Math.max(0, progress.after + line.amount));
77
+ progress.after = roundCents(Math.max(0, progress.after + line.amount));
80
78
  progress.entries.push(entry);
81
79
  }
82
80
  const { name, merchant, known } = resolveLine(catalog, line);
@@ -96,7 +94,7 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
96
94
  const signed = line.amount < 0
97
95
  ? {
98
96
  ...booking,
99
- after: round(Math.max(0, booking.before + line.amount)),
97
+ after: roundCents(Math.max(0, booking.before + line.amount)),
100
98
  }
101
99
  : booking;
102
100
  bookings.push({ ...entry, booking: signed });
@@ -181,21 +179,21 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
181
179
  from: sorted[0]?.line.date ?? '',
182
180
  to: sorted.at(-1)?.line.date ?? '',
183
181
  purchases,
184
- spend: round(spend),
185
- refunds: round(refunds),
186
- earned: round(earned),
187
- best: round(best),
182
+ spend: roundCents(spend),
183
+ refunds: roundCents(refunds),
184
+ earned: roundCents(earned),
185
+ best: roundCents(best),
188
186
  byCard: [...byCard.values()].map((t) => ({
189
187
  ...t,
190
- spend: round(t.spend),
191
- earned: round(t.earned),
188
+ spend: roundCents(t.spend),
189
+ earned: roundCents(t.earned),
192
190
  })),
193
191
  missed: [...missed.values()]
194
192
  .map((m) => ({
195
193
  ...m,
196
- spend: round(m.spend),
197
- earned: round(m.earned),
198
- best: round(m.best),
194
+ spend: roundCents(m.spend),
195
+ earned: roundCents(m.earned),
196
+ best: roundCents(m.best),
199
197
  }))
200
198
  .sort((a, b) => b.best - b.earned - (a.best - a.earned)),
201
199
  coding: [...coding.values()].sort((a, b) => b.count - a.count),