earnrate 0.2.4 → 0.2.5

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 (62) hide show
  1. package/README.md +5 -5
  2. package/data/CHANGELOG.md +39 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-gold.yaml +4 -0
  5. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  6. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  8. package/data/cards/chase-freedom-flex.yaml +1 -1
  9. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  10. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  11. package/data/cards/chase-united-club.yaml +1 -1
  12. package/data/cards/citi-costco-anywhere-visa-business.yaml +1 -7
  13. package/data/cards/citi-costco-anywhere-visa.yaml +1 -7
  14. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  15. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  16. package/data/categories.yaml +12 -0
  17. package/data/currencies.yaml +2 -0
  18. package/data/memberships.yaml +33 -0
  19. package/data/merchants/overrides.yaml +25 -0
  20. package/dist/cli/cheatsheet.js +2 -2
  21. package/dist/cli/explore.js +11 -10
  22. package/dist/cli/import.js +9 -9
  23. package/dist/cli/offers.js +2 -1
  24. package/dist/cli/run.js +17 -3
  25. package/dist/cli/wallet.js +42 -0
  26. package/dist/data/file-store.js +5 -2
  27. package/dist/data/integrity.js +18 -2
  28. package/dist/data/load.js +10 -2
  29. package/dist/data/store.js +8 -1
  30. package/dist/data/wallet-edit.js +75 -0
  31. package/dist/engine/cheat-sheet.js +14 -17
  32. package/dist/engine/compare.js +8 -0
  33. package/dist/engine/explore.js +10 -21
  34. package/dist/engine/memberships.js +41 -15
  35. package/dist/engine/offers.js +16 -4
  36. package/dist/engine/profiles.js +8 -0
  37. package/dist/engine/recommend.js +10 -23
  38. package/dist/engine/setup.js +14 -16
  39. package/dist/engine/simulate.js +5 -5
  40. package/dist/engine/text.js +36 -22
  41. package/dist/engine/valuation.js +1 -1
  42. package/dist/engine/wallet-change.js +17 -0
  43. package/dist/engine/wallet-summary.js +24 -6
  44. package/dist/format/card-text.js +15 -19
  45. package/dist/format/cards.js +3 -3
  46. package/dist/format/explore.js +14 -15
  47. package/dist/format/offers.js +10 -15
  48. package/dist/format/recommendation.js +5 -5
  49. package/dist/format/wallet.js +4 -1
  50. package/dist/mcp/context.js +31 -4
  51. package/dist/mcp/inputs.js +15 -6
  52. package/dist/mcp/schemas.js +8 -0
  53. package/dist/mcp/server.js +1 -0
  54. package/dist/mcp/tools/account.js +1 -1
  55. package/dist/mcp/tools/answers.js +28 -12
  56. package/dist/mcp/tools/wallet.js +58 -5
  57. package/dist/schema/card.js +4 -11
  58. package/dist/schema/category.js +4 -0
  59. package/dist/schema/currency.js +2 -0
  60. package/dist/schema/membership.js +15 -0
  61. package/dist/schema/merchant.js +4 -0
  62. package/package.json +1 -1
@@ -1,30 +1,48 @@
1
- import { money } from "./text.js";
1
+ import { money, words } from "./text.js";
2
+ export function membership(catalog, id) {
3
+ return catalog.memberships.find((m) => m.id === id);
4
+ }
5
+ export function membershipName(catalog, id) {
6
+ return membership(catalog, id)?.name ?? words(id);
7
+ }
8
+ // "a Costco membership", for a sentence.
9
+ export function withArticle(name) {
10
+ return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
11
+ }
12
+ // Tiers of one membership: holding one, the others in its group aren't
13
+ // missing (BofA Preferred Rewards).
14
+ export function sameGroup(catalog, a, b) {
15
+ const group = membership(catalog, a)?.group;
16
+ return group !== undefined && group === membership(catalog, b)?.group;
17
+ }
2
18
  // Memberships the person already pays for: the ones they've said, the ones a
3
19
  // card they hold needs, and the ones their spending shows (only members shop
4
20
  // at Costco).
5
21
  export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
6
22
  const paid = new Set(memberships);
7
23
  for (const card of catalog.cards) {
8
- const m = card.requires_membership;
9
- if (!m)
10
- continue;
11
- if (heldCards.includes(card.id) ||
12
- m.members_shop_at.some((id) => merchantIds.includes(id)))
13
- paid.add(m.id);
24
+ const id = card.requires_membership;
25
+ if (id !== undefined && heldCards.includes(card.id))
26
+ paid.add(id);
14
27
  }
28
+ for (const m of catalog.memberships)
29
+ if (m.members_shop_at.some((id) => merchantIds.includes(id)))
30
+ paid.add(m.id);
15
31
  return paid;
16
32
  }
17
33
  // What these cards' memberships add, once each, leaving out the paid ones.
18
- export function membershipCosts(cards, paid) {
34
+ export function membershipCosts(catalog, cards, paid) {
19
35
  const costs = new Map();
20
36
  for (const card of cards) {
21
- const m = card.requires_membership;
37
+ const m = card.requires_membership === undefined
38
+ ? undefined
39
+ : membership(catalog, card.requires_membership);
22
40
  if (!m || paid.has(m.id))
23
41
  continue;
24
42
  const cost = costs.get(m.id) ?? {
25
43
  id: m.id,
26
44
  name: m.name,
27
- annualFee: m.annual_fee,
45
+ annualFee: m.annual_fee ?? 0,
28
46
  cardIds: [],
29
47
  };
30
48
  cost.cardIds.push(card.id);
@@ -32,14 +50,22 @@ export function membershipCosts(cards, paid) {
32
50
  }
33
51
  return [...costs.values()];
34
52
  }
53
+ // "a Costco membership ($65 a year)"
54
+ export function membershipText(catalog, id) {
55
+ const m = membership(catalog, id);
56
+ const name = withArticle(m?.name ?? words(id));
57
+ return m?.annual_fee === undefined
58
+ ? name
59
+ : `${name} (${money(m.annual_fee)} a year)`;
60
+ }
35
61
  // The card's membership as a condition, unless it's paid for.
36
- export function membershipNeeded(card, paid) {
37
- const m = card.requires_membership;
38
- return m && !paid.has(m.id)
39
- ? [`Needs ${m.name} (${money(m.annual_fee)} a year).`]
62
+ export function membershipNeeded(catalog, card, paid) {
63
+ const id = card.requires_membership;
64
+ return id !== undefined && !paid.has(id)
65
+ ? [`Needs ${membershipText(catalog, id)}.`]
40
66
  : [];
41
67
  }
42
68
  export function membershipNote(catalog, m) {
43
69
  const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
44
- return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${m.name}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
70
+ return `${names.join(' and ')} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
45
71
  }
@@ -1,7 +1,7 @@
1
1
  import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
- import { roundCents } from "./text.js";
4
+ import { money, roundCents } from "./text.js";
5
5
  import { cardValuation } from "./valuation.js";
6
6
  function ineligibleText(catalog, wallet, notWith) {
7
7
  const held = wallet.cards
@@ -13,6 +13,18 @@ function ineligibleText(catalog, wallet, notWith) {
13
13
  ? null
14
14
  : `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
15
15
  }
16
+ export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
17
+ // "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
18
+ // months": one wording for the site, the search pages and the CLI.
19
+ export function offerTerms(o, currencyName) {
20
+ const amount = o.points !== null && o.points !== undefined
21
+ ? `${o.points.toLocaleString('en-US')} ${currencyName}`
22
+ : money(o.dollars ?? 0);
23
+ const spend = o.spend > 0
24
+ ? `after ${money(o.spend)} in purchases within ${String(o.months)} month${o.months === 1 ? '' : 's'}`
25
+ : 'with no spending required';
26
+ return `${o.varies ? 'Up to ' : ''}${amount} ${spend}`;
27
+ }
16
28
  export function compareOffers(catalog, wallet, filters, today) {
17
29
  const held = new Set(wallet.cards.map((c) => c.id));
18
30
  const paid = paidMemberships(catalog, {
@@ -37,7 +49,7 @@ export function compareOffers(catalog, wallet, filters, today) {
37
49
  const value = bonusDollars(card, valuation.centsPerUnit, offer.points, offer.dollars) ??
38
50
  0;
39
51
  const firstYearFee = card.first_year_fee ?? card.annual_fee ?? null;
40
- const membershipFee = membershipCosts([card], paid)[0]?.annualFee ?? 0;
52
+ const membershipFee = membershipCosts(catalog, [card], paid)[0]?.annualFee ?? 0;
41
53
  offers.push({
42
54
  cardId: card.id,
43
55
  cardName: card.name,
@@ -68,8 +80,8 @@ export function compareOffers(catalog, wallet, filters, today) {
68
80
  a.cardId.localeCompare(b.cardId));
69
81
  const notes = [
70
82
  `Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
71
- "Each offer is the public one on the issuer's page on the date shown, checked weekly. Yours may differ: issuers test and target offers, so use the one shown to you when you apply. Issuers also limit who can get a bonus; each card's note says how.",
72
- "First-year value is the bonus at your point values minus the first year's annual fee. It leaves out what the card earns on your spending and any credits or perks.",
83
+ `Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
84
+ "First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
73
85
  ];
74
86
  return { offers, withoutOffer, notes };
75
87
  }
@@ -2,6 +2,14 @@ export const VALUATION_PROFILES = {
2
2
  flat: { MR: 1.0, UR: 1.0 },
3
3
  'points-heavy': { MR: 1.7, UR: 1.7 },
4
4
  };
5
+ // "MR and UR at 1.7¢": what a profile sets, for help text.
6
+ export function profileText(profile) {
7
+ const values = Object.entries(VALUATION_PROFILES[profile]);
8
+ const cents = [...new Set(values.map(([, v]) => v))];
9
+ return cents.length === 1
10
+ ? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
11
+ : values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
12
+ }
5
13
  export function isValuationProfile(name) {
6
14
  return Object.hasOwn(VALUATION_PROFILES, name);
7
15
  }
@@ -3,21 +3,13 @@ import { bonusStatus } from "./bonus.js";
3
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, NETWORK_NAMES, num, pointValuesText, shortDate, } from "./text.js";
6
+ import { EPSILON, isClose } from "./compare.js";
7
+ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
7
8
  import { cardValuation } from "./valuation.js";
8
- // An on-pace welcome bonus card is mentioned when it's this close to the
9
- // best card: within a point, and at least three quarters of its rate.
10
- const CLOSE_PCT = 1;
11
- const CLOSE_SHARE = 0.75;
12
- const EPSILON = 1e-9;
13
9
  export const STALE_AFTER_DAYS = 180;
10
+ // The warning when no card works; the CLI shows it as the answer instead.
11
+ export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
14
12
  const NEAR_CAP_SHARE = 0.2;
15
- const PERIOD_WORDS = {
16
- month: 'monthly',
17
- quarter: 'quarterly',
18
- calendar_year: 'calendar-year',
19
- cardmember_year: 'cardmember-year',
20
- };
21
13
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
22
14
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
23
15
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
@@ -121,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
121
113
  const { best, nominal } = evaluation;
122
114
  const warnings = [];
123
115
  const capLabel = (e) => e.cap
124
- ? `${money(e.cap.cap.amount)} ${PERIOD_WORDS[e.cap.cap.period] ?? ''} cap`
116
+ ? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
125
117
  : '';
126
118
  if (best.cap && best.rule) {
127
119
  const { remaining, cap, period } = best.cap;
@@ -292,7 +284,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
292
284
  }
293
285
  }
294
286
  if (!winner) {
295
- warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
287
+ warnings.push(noCardText(merchant.resolvedName));
296
288
  // Only where the merchant's data says what its website takes: without
297
289
  // that, every network is assumed, and a gas pump has no website.
298
290
  const record = [
@@ -348,10 +340,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
348
340
  const ties = !isBest && gap <= EPSILON;
349
341
  const bonus = open && (open.behind || ties) ? open : null;
350
342
  const best = winner?.effectiveReturnPct ?? 0;
351
- if (open &&
352
- !bonus &&
353
- gap <= CLOSE_PCT &&
354
- open.entry.effectiveReturnPct >= best * CLOSE_SHARE) {
343
+ if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
355
344
  notes.push(`${displayName(open.entry)} earns ${num(open.entry.effectiveReturnPct)}% here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${num(best)}%, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
356
345
  }
357
346
  const chosen = bonus?.entry ?? winner;
@@ -369,7 +358,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
369
358
  const daysLeft = status.daysLeft ?? 0;
370
359
  const costHere = ties || top.earned === null || first.earned === null
371
360
  ? null
372
- : Math.round((first.earned - top.earned) * 100) / 100;
361
+ : roundCents(first.earned - top.earned);
373
362
  bonusResult = {
374
363
  cardId: top.cardId,
375
364
  checkoutLabel: top.checkoutLabel,
@@ -380,11 +369,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
380
369
  costHere,
381
370
  };
382
371
  const pace = daysLeft >= 7
383
- ? ` (about ${money(Math.round(remaining / (daysLeft / 7)))} a week)`
372
+ ? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
384
373
  : '';
385
- const worth = status.value === null
386
- ? ''
387
- : `, worth about ${money(Math.round(status.value))}`;
374
+ const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
388
375
  notes.unshift(ties
389
376
  ? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
390
377
  : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
@@ -1,4 +1,5 @@
1
1
  import { isActivated, pickedCategories } from "./evaluate.js";
2
+ import { membership, membershipName, sameGroup } from "./memberships.js";
2
3
  import { daysBetween } from "./periods.js";
3
4
  import { rotationGap } from "./rotation.js";
4
5
  // Rules that start within this many days are worth activating now.
@@ -57,9 +58,6 @@ export function activationRules(card, today) {
57
58
  daysBetween(today, r.valid_from) <= ACTIVATION_LOOKAHEAD_DAYS))
58
59
  .map((r) => r.id);
59
60
  }
60
- // Bank of America Preferred Rewards tiers: a person holds one at a time, so
61
- // with one saved the others aren't missing.
62
- const TIER = /^bofa-rewards-/;
63
61
  export function membershipsUsed(card) {
64
62
  return [
65
63
  ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
@@ -70,13 +68,13 @@ export function membershipsUsed(card) {
70
68
  export function membershipsFor(card) {
71
69
  return [
72
70
  ...membershipsUsed(card),
73
- ...(card.requires_membership ? [card.requires_membership.id] : []),
71
+ ...(card.requires_membership ? [card.requires_membership] : []),
74
72
  ];
75
73
  }
76
74
  export function knownMemberships(catalog) {
77
- return [...new Set(catalog.cards.flatMap(membershipsFor))].sort();
75
+ return catalog.memberships.map((m) => m.id).sort();
78
76
  }
79
- export function cardInfo(card, wallet, today) {
77
+ export function cardInfo(catalog, card, wallet, today) {
80
78
  return {
81
79
  id: card.id,
82
80
  name: card.name,
@@ -95,13 +93,13 @@ export function cardInfo(card, wallet, today) {
95
93
  default: c.default ?? null,
96
94
  })),
97
95
  memberships: membershipsUsed(card),
98
- requiresMembership: card.requires_membership
99
- ? {
100
- id: card.requires_membership.id,
101
- name: card.requires_membership.name,
102
- annualFee: card.requires_membership.annual_fee,
103
- }
104
- : null,
96
+ requiresMembership: card.requires_membership === undefined
97
+ ? null
98
+ : {
99
+ id: card.requires_membership,
100
+ name: membershipName(catalog, card.requires_membership),
101
+ annualFee: membership(catalog, card.requires_membership)?.annual_fee ?? 0,
102
+ },
105
103
  activation: activationRules(card, today),
106
104
  needsAnniversary: needsAnniversary(card),
107
105
  };
@@ -129,7 +127,7 @@ export function searchCards(catalog, query, wallet, today) {
129
127
  .sort(byShortestId)
130
128
  .slice(0, 5);
131
129
  return {
132
- cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
130
+ cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
133
131
  exact,
134
132
  };
135
133
  }
@@ -155,8 +153,8 @@ export function setupNotes(catalog, wallet, held, today) {
155
153
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
156
154
  });
157
155
  }
158
- const hasTier = wallet.memberships.some((m) => TIER.test(m));
159
- const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(m) && !(hasTier && TIER.test(m)));
156
+ // With one tier of a group saved, the others aren't missing.
157
+ const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
160
158
  if (missing.length > 0) {
161
159
  notes.push({
162
160
  kind: 'membership',
@@ -5,9 +5,9 @@ import { membershipCosts, membershipNote, paidMemberships, } from "./memberships
5
5
  import { choicePeriod, periodKey } from "./periods.js";
6
6
  import { applyBooking, capBooking } from "./record.js";
7
7
  import { rotationGap } from "./rotation.js";
8
- import { roundCents } from "./text.js";
8
+ import { money, roundCents } from "./text.js";
9
9
  import { cardValuation } from "./valuation.js";
10
- const EPSILON = 1e-9;
10
+ import { EPSILON } from "./compare.js";
11
11
  const MONTHS = 12;
12
12
  const SHORTLIST = 8; // best single cards considered for pairs
13
13
  const PER_LINE = 2; // plus the best cards for each spending line
@@ -129,7 +129,7 @@ held = []) {
129
129
  heldCards: held,
130
130
  merchantIds: lines.map((l) => l.merchant.merchantId),
131
131
  });
132
- const memberships = membershipCosts(cards.map((c) => c.card), paid);
132
+ const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
133
133
  const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
134
134
  const notes = [];
135
135
  for (const m of memberships)
@@ -146,7 +146,7 @@ held = []) {
146
146
  else if (!held.includes(card.id) &&
147
147
  card.first_year_fee !== undefined &&
148
148
  card.first_year_fee !== card.annual_fee)
149
- notes.push(`${card.name}'s fee is $${String(card.first_year_fee)} the first year; the yearly figure uses the $${String(card.annual_fee)} that follows.`);
149
+ notes.push(`${card.name}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
150
150
  }
151
151
  return {
152
152
  cards: wallet.cards.map((h) => ({
@@ -342,4 +342,4 @@ export function spendLineFor(catalog, what, monthly) {
342
342
  export function typicalSpendLines(catalog) {
343
343
  return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
344
344
  }
345
- 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.`;
345
+ 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} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
@@ -4,15 +4,22 @@ export const pct = (n) => `${num(n)}%`;
4
4
  export const roundCents = (n) => Math.round(n * 100) / 100;
5
5
  // An id in words: "gas-ev-charging" reads "gas ev charging".
6
6
  export const words = (id) => id.replace(/[-_.]/g, ' ');
7
- // "Dec 31, 2026" from YYYY-MM-DD, the same in every time zone.
8
- export const shortDate = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
9
- month: 'short',
10
- day: 'numeric',
11
- year: 'numeric',
7
+ const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
8
+ ...options,
12
9
  timeZone: 'UTC',
13
10
  });
14
- // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
15
- export const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
11
+ // From YYYY-MM-DD, the same in every time zone: "Dec 31, 2026",
12
+ // "December 31, 2026", "December 2026".
13
+ export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
14
+ export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
15
+ export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
16
+ // Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
17
+ export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
18
+ // Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
19
+ export const wholeDollars = (n) => money(Math.round(n));
20
+ // Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
21
+ // loss reads −$618.
22
+ export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
16
23
  export function feeText(fees) {
17
24
  const { annualFee, firstYearFee } = fees;
18
25
  if (annualFee === null)
@@ -69,13 +76,13 @@ export const NETWORK_NAMES = {
69
76
  };
70
77
  // A name with its own "and" ("drugstores and pharmacies") would run into the
71
78
  // list's: those lists use commas only, with those names last.
72
- export const listed = (parts) => {
79
+ export const listed = (parts, joiner = 'and') => {
73
80
  const anded = parts.filter((p) => p.includes(' and '));
74
81
  if (anded.length > 0)
75
82
  return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
76
83
  return parts.length <= 2
77
- ? parts.join(' and ')
78
- : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
84
+ ? parts.join(` ${joiner} `)
85
+ : `${parts.slice(0, -1).join(', ')} ${joiner} ${parts.at(-1) ?? ''}`;
79
86
  };
80
87
  const NUMBERS = {
81
88
  one: '1',
@@ -89,16 +96,23 @@ const NUMBERS = {
89
96
  export function choiceName(id) {
90
97
  return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
91
98
  }
92
- // Display names for membership ids in the card data.
93
- export const MEMBERSHIPS = {
94
- 'amazon-prime': 'Amazon Prime',
95
- 'bank-of-america-account': 'Bank of America checking or savings',
96
- 'bank-of-america-small-business-account': 'Bank of America small business checking or savings',
97
- 'bofa-rewards-member': 'BofA Rewards Member',
98
- 'bofa-rewards-preferred-plus': 'BofA Rewards Preferred Plus',
99
- 'bofa-rewards-preferred-honors': 'BofA Rewards Preferred Honors',
100
- 'bofa-rewards-premier': 'BofA Rewards Premier',
101
- costco: 'Costco membership',
102
- 'nordy-club-ambassador': 'Nordy Club Ambassador',
99
+ // Period words in one place: a cap of $1,500 "a quarter", a "quarterly" cap,
100
+ // a category picked "each quarter".
101
+ export const CAP_PERIODS = {
102
+ month: 'a month',
103
+ quarter: 'a quarter',
104
+ calendar_year: 'a calendar year',
105
+ cardmember_year: 'a cardmember year',
106
+ };
107
+ export const CAP_ADJECTIVES = {
108
+ month: 'monthly',
109
+ quarter: 'quarterly',
110
+ calendar_year: 'calendar-year',
111
+ cardmember_year: 'cardmember-year',
112
+ };
113
+ export const PICK_PERIODS = {
114
+ month: 'each month',
115
+ quarter: 'each quarter',
116
+ calendar_year: 'each year',
117
+ until_changed: 'until you change it',
103
118
  };
104
- export const membershipName = (id) => MEMBERSHIPS[id] ?? words(id);
@@ -1,4 +1,4 @@
1
- function currencyValuation(catalog, wallet, currencyId) {
1
+ export 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}'`);
@@ -251,3 +251,20 @@ export function withMemberships(catalog, wallet, memberships) {
251
251
  notes: [],
252
252
  };
253
253
  }
254
+ // Point values in cents per point, by currency id; null goes back to
255
+ // earnrate's default for that currency.
256
+ export function withValuations(catalog, wallet, changes) {
257
+ const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
258
+ if (unknown.length > 0)
259
+ return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${list(catalog.currencies.map((c) => c.id))}.`);
260
+ const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
261
+ if (odd.length > 0)
262
+ return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
263
+ const valuations = Object.fromEntries([
264
+ ...Object.entries(wallet.valuations).filter(([id]) => !(id in changes)),
265
+ ...Object.entries(changes).filter(([, cents]) => cents !== null),
266
+ ]);
267
+ return { ok: true, wallet: { ...wallet, valuations }, fields: {}, notes: [] };
268
+ }
269
+ // Higher than any program's points are worth, even transferred.
270
+ const MAX_CENTS = 5;
@@ -5,16 +5,19 @@ import { STALE_AFTER_DAYS } from "./recommend.js";
5
5
  import { rotationGap } from "./rotation.js";
6
6
  import { activationRules } from "./setup.js";
7
7
  import { categoryPhrase, choiceName, currencyLabel, earnText, listed, words, } from "./text.js";
8
- import { cardValuation } from "./valuation.js";
8
+ import { cardValuation, currencyValuation } from "./valuation.js";
9
9
  // Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
10
- // lower case and plural; brand names keep theirs.
10
+ // lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
11
11
  const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
12
12
  function merchantName(catalog, id) {
13
13
  const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
14
- const name = all.find((m) => m.id === id)?.name ?? words(id);
15
- return name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`);
14
+ const merchant = all.find((m) => m.id === id);
15
+ const name = merchant?.name ?? words(id);
16
+ return merchant?.kind === 'booking'
17
+ ? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
18
+ : name;
16
19
  }
17
- function optionName(catalog, card, choiceId, option) {
20
+ export function optionName(catalog, card, choiceId, option) {
18
21
  const o = card.choices.find((c) => c.id === choiceId)?.options[option];
19
22
  if (!o)
20
23
  return words(option);
@@ -148,6 +151,13 @@ function capSummaries(catalog, card, held, today) {
148
151
  };
149
152
  });
150
153
  }
154
+ // "into a SoFi account", for a wallet that values points by a redemption.
155
+ function redemptionOf(catalog, currency, v) {
156
+ if (typeof v !== 'object' || v.redemption === undefined)
157
+ return null;
158
+ const names = catalog.currencies.find((c) => c.id === currency)?.redemption_names;
159
+ return names?.[v.redemption] ?? `as ${words(v.redemption)}`;
160
+ }
151
161
  export function walletSummary(catalog, wallet, today) {
152
162
  const cards = [];
153
163
  for (const held of wallet.cards) {
@@ -194,5 +204,13 @@ export function walletSummary(catalog, wallet, today) {
194
204
  anniversary: held.anniversary ?? null,
195
205
  });
196
206
  }
197
- return { memberships: wallet.memberships, cards };
207
+ const pointValues = Object.keys(wallet.valuations)
208
+ .filter((id) => catalog.currencies.some((c) => c.id === id))
209
+ .map((id) => ({
210
+ currency: id,
211
+ name: currencyLabel(catalog, id),
212
+ centsPerUnit: currencyValuation(catalog, wallet, id).centsPerUnit,
213
+ redemption: redemptionOf(catalog, id, wallet.valuations[id]),
214
+ }));
215
+ return { memberships: wallet.memberships, pointValues, started: null, cards };
198
216
  }
@@ -13,22 +13,11 @@ const ISSUERS = {
13
13
  usbank: 'U.S. Bank',
14
14
  'wells-fargo': 'Wells Fargo',
15
15
  };
16
- export const PICK_PERIODS = {
17
- month: 'each month',
18
- quarter: 'each quarter',
19
- calendar_year: 'each year',
20
- until_changed: 'until you change it',
21
- };
22
- export const CAP_PERIODS = {
23
- month: 'a month',
24
- quarter: 'a quarter',
25
- calendar_year: 'a year',
26
- cardmember_year: 'a card year',
27
- };
28
- export { MEMBERSHIPS, membershipName, words } from "../engine/text.js";
29
- import { membershipName, words } from "../engine/text.js";
16
+ export { CAP_PERIODS, PICK_PERIODS, words } from "../engine/text.js";
17
+ import { membershipName, membershipText } from "../engine/memberships.js";
18
+ import { CAP_PERIODS, PICK_PERIODS, wholeDollars, words, } from "../engine/text.js";
30
19
  // Whole dollars, for fees and caps.
31
- export const usd = (n) => `${n < 0 ? '−' : ''}$${Math.abs(Math.round(n)).toLocaleString('en-US')}`;
20
+ export const usd = wholeDollars;
32
21
  export const issuerName = (id) => ISSUERS[id] ?? id;
33
22
  export function cardText(catalog) {
34
23
  const cards = new Map(catalog.cards.map((c) => [c.id, c]));
@@ -43,7 +32,7 @@ export function cardText(catalog) {
43
32
  function feeLabel(id) {
44
33
  const card = cards.get(id);
45
34
  const m = card?.requires_membership;
46
- const needs = m ? `; needs ${m.name} (${usd(m.annual_fee)} a year)` : '';
35
+ const needs = m === undefined ? '' : `; needs ${membershipText(catalog, m)}`;
47
36
  const fee = card?.annual_fee;
48
37
  if (fee === undefined)
49
38
  return 'Annual fee not on file';
@@ -62,12 +51,19 @@ export function cardText(catalog) {
62
51
  if (r.activation !== null)
63
52
  lines.push(`Activate the bonus with ${issuerName(cards.get(cardId)?.issuer ?? '')} (free)`);
64
53
  if (r.membership !== null)
65
- lines.push(`With ${membershipName(r.membership)}`);
54
+ lines.push(`With ${membershipName(catalog, r.membership)}`);
66
55
  if (until !== null)
67
56
  lines.push(`This rate runs through ${until}`);
68
57
  if (r.cap)
69
- lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period] ?? ''}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
58
+ lines.push(`Up to ${usd(r.cap.amount)} of spending ${CAP_PERIODS[r.cap.period]}, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}`);
70
59
  return lines;
71
60
  }
72
- return { cards, shortName, feeLabel, requirementLines };
61
+ const memberName = (id) => membershipName(catalog, id);
62
+ return {
63
+ cards,
64
+ shortName,
65
+ feeLabel,
66
+ requirementLines,
67
+ membershipName: memberName,
68
+ };
73
69
  }
@@ -1,15 +1,15 @@
1
- import { NETWORK_NAMES } from "../engine/text.js";
1
+ import { money, NETWORK_NAMES } from "../engine/text.js";
2
2
  export function formatCardInfo(card) {
3
3
  const fee = card.annualFee === null
4
4
  ? ''
5
5
  : card.annualFee === 0
6
6
  ? ', no annual fee'
7
- : `, $${String(card.annualFee)} a year`;
7
+ : `, ${money(card.annualFee)} a year`;
8
8
  const needs = [
9
9
  ...card.choices.map((c) => `${c.id}: pick ${String(c.pick)} of ${c.options.join(', ')}${c.default ? ` (default ${c.default.join(', ')})` : ''}`),
10
10
  ...(card.requiresMembership
11
11
  ? [
12
- `needs ${card.requiresMembership.name} ($${String(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
12
+ `needs ${card.requiresMembership.name} (${money(card.requiresMembership.annualFee)} a year; membership id ${card.requiresMembership.id})`,
13
13
  ]
14
14
  : []),
15
15
  ...(card.memberships.length > 0