earnrate 0.2.3 → 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 (71) hide show
  1. package/README.md +15 -14
  2. package/data/CHANGELOG.md +74 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +5 -4
  5. package/data/cards/amex-gold.yaml +4 -0
  6. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
  7. package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
  8. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
  9. package/data/cards/chase-freedom-flex.yaml +1 -1
  10. package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
  11. package/data/cards/chase-ihg-one-rewards.yaml +7 -0
  12. package/data/cards/chase-southwest-plus.yaml +4 -0
  13. package/data/cards/chase-southwest-premier.yaml +4 -0
  14. package/data/cards/chase-southwest-priority.yaml +4 -0
  15. package/data/cards/chase-united-club.yaml +1 -1
  16. package/data/cards/citi-aadvantage-globe.yaml +7 -7
  17. package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
  18. package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
  19. package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
  20. package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
  21. package/data/categories.yaml +12 -0
  22. package/data/currencies.yaml +2 -0
  23. package/data/memberships.yaml +33 -0
  24. package/data/merchants/overrides.yaml +33 -0
  25. package/data/merchants/seed.yaml +0 -6
  26. package/dist/cli/cheatsheet.js +3 -3
  27. package/dist/cli/explore.js +12 -11
  28. package/dist/cli/import.js +13 -12
  29. package/dist/cli/offers.js +2 -1
  30. package/dist/cli/run.js +18 -4
  31. package/dist/cli/wallet.js +42 -0
  32. package/dist/data/file-store.js +5 -2
  33. package/dist/data/integrity.js +26 -0
  34. package/dist/data/load.js +10 -2
  35. package/dist/data/store.js +8 -1
  36. package/dist/data/wallet-edit.js +83 -2
  37. package/dist/engine/cheat-sheet.js +53 -14
  38. package/dist/engine/compare.js +8 -0
  39. package/dist/engine/explore.js +18 -20
  40. package/dist/engine/memberships.js +71 -0
  41. package/dist/engine/offers.js +37 -5
  42. package/dist/engine/profiles.js +8 -0
  43. package/dist/engine/recommend.js +78 -44
  44. package/dist/engine/setup.js +22 -5
  45. package/dist/engine/simulate.js +36 -14
  46. package/dist/engine/text.js +67 -2
  47. package/dist/engine/valuation.js +1 -1
  48. package/dist/engine/wallet-change.js +42 -4
  49. package/dist/engine/wallet-summary.js +83 -37
  50. package/dist/format/card-text.js +19 -29
  51. package/dist/format/cards.js +8 -9
  52. package/dist/format/explore.js +22 -14
  53. package/dist/format/offers.js +12 -21
  54. package/dist/format/recommendation.js +16 -10
  55. package/dist/format/wallet-yaml.js +18 -3
  56. package/dist/format/wallet.js +16 -20
  57. package/dist/mcp/context.js +31 -4
  58. package/dist/mcp/inputs.js +17 -8
  59. package/dist/mcp/schemas.js +21 -0
  60. package/dist/mcp/server.js +1 -0
  61. package/dist/mcp/tools/account.js +28 -10
  62. package/dist/mcp/tools/answers.js +51 -18
  63. package/dist/mcp/tools/wallet.js +106 -11
  64. package/dist/schema/card.js +7 -0
  65. package/dist/schema/category.js +4 -0
  66. package/dist/schema/currency.js +2 -0
  67. package/dist/schema/membership.js +15 -0
  68. package/dist/schema/merchant.js +4 -0
  69. package/dist/schema/wallet.js +4 -0
  70. package/package.json +1 -1
  71. package/wallet.example.yaml +1 -0
@@ -3,23 +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, num, pointValuesText, } 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
- const EPSILON = 1e-9;
9
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}.`;
10
12
  const NEAR_CAP_SHARE = 0.2;
11
- const NETWORK_NAMES = {
12
- visa: 'Visa',
13
- mastercard: 'Mastercard',
14
- amex: 'American Express',
15
- discover: 'Discover',
16
- };
17
- const PERIOD_WORDS = {
18
- month: 'monthly',
19
- quarter: 'quarterly',
20
- calendar_year: 'calendar-year',
21
- cardmember_year: 'cardmember-year',
22
- };
23
13
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
24
14
  // "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
25
15
  const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
@@ -123,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
123
113
  const { best, nominal } = evaluation;
124
114
  const warnings = [];
125
115
  const capLabel = (e) => e.cap
126
- ? `${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`
127
117
  : '';
128
118
  if (best.cap && best.rule) {
129
119
  const { remaining, cap, period } = best.cap;
@@ -154,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
154
144
  warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
155
145
  }
156
146
  for (const rule of evaluation.needsActivation) {
157
- warnings.push(`Activate "${rule.id}" with the issuer to earn ${earnText(catalog, rule.rate, valuation.currency)} here.`);
147
+ warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
158
148
  }
159
149
  return warnings;
160
150
  }
@@ -264,7 +254,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
264
254
  const warnings = [];
265
255
  const notes = [];
266
256
  if (merchant.source === 'none') {
267
- warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. A more specific name may find a bonus category.`);
257
+ warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
268
258
  }
269
259
  if (merchant.source === 'caller') {
270
260
  warnings.push(`"${merchant.input}" isn't in earnrate's merchant list, so it uses the category given with the request. Check how it codes on your statement.`);
@@ -279,16 +269,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
279
269
  const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
280
270
  warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
281
271
  for (const category of merchant.ambiguity.mayCodeAs) {
282
- const alt = rankEntries(catalog, wallet, purchase, merchant, category)
283
- .entries[0];
284
- if (alt && winner && alt.card.id !== winner.card.id) {
285
- warnings.push(`If it codes as ${categoryPhrase(catalog, category)}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
286
- }
272
+ const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
273
+ const alt = alts[0];
274
+ if (!alt || !winner)
275
+ continue;
276
+ const as = `If it codes as ${categoryPhrase(catalog, category)}`;
277
+ // The same pick there may share the top with another card.
278
+ const twin = alts.find((e) => e.card.id !== winner.card.id &&
279
+ alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
280
+ if (alt.card.id !== winner.card.id)
281
+ warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
282
+ else if (twin)
283
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
287
284
  }
288
285
  }
289
286
  if (!winner) {
290
- warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
291
- const online = merchant.channel === 'in_store' && merchant.merchantId !== null
287
+ warnings.push(noCardText(merchant.resolvedName));
288
+ // Only where the merchant's data says what its website takes: without
289
+ // that, every network is assumed, and a gas pump has no website.
290
+ const record = [
291
+ ...catalog.merchants.overrides,
292
+ ...catalog.merchants.seed,
293
+ ].find((m) => m.id === merchant.merchantId);
294
+ const online = merchant.channel === 'in_store' &&
295
+ merchant.merchantId !== null &&
296
+ record?.online?.accepts !== undefined
292
297
  ? resolveMerchantById(catalog, merchant.merchantId, 'online')
293
298
  : null;
294
299
  const alt = online
@@ -313,9 +318,6 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
313
318
  warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
314
319
  }
315
320
  }
316
- const tie = tieNote(entries);
317
- if (tie)
318
- notes.push(tie);
319
321
  for (const id of merchant.bookedVia) {
320
322
  const via = resolveMerchantById(catalog, id, purchase.channel);
321
323
  if (!via || !winner)
@@ -326,19 +328,37 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
326
328
  notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
327
329
  }
328
330
  }
329
- const bonus = bonusPick(catalog, wallet, entries, purchase.date);
331
+ const open = bonusPick(catalog, wallet, entries, purchase.date);
332
+ // Behind pace, the bonus card takes the pick; on pace, only where it earns
333
+ // as much as the best card (it costs nothing there). Within a point, it's
334
+ // mentioned; otherwise the bonus isn't this purchase's business.
335
+ const gap = open && winner
336
+ ? winner.effectiveReturnPct - open.entry.effectiveReturnPct
337
+ : Infinity;
338
+ // Already the best card, it's the pick anyway: a tie needs no note.
339
+ const isBest = open !== null && open.entry === winner;
340
+ const ties = !isBest && gap <= EPSILON;
341
+ const bonus = open && (open.behind || ties) ? open : null;
342
+ const best = winner?.effectiveReturnPct ?? 0;
343
+ if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
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.`);
345
+ }
330
346
  const chosen = bonus?.entry ?? winner;
347
+ // A tie among the top cards doesn't matter when a welcome bonus decides.
348
+ const tie = bonus ? null : tieNote(entries);
349
+ if (tie)
350
+ notes.push(tie);
331
351
  const top = ranked.find((r) => r.cardId === chosen?.card.id);
332
- const best = ranked[0];
352
+ const first = ranked[0];
333
353
  let bonusResult = null;
334
- if (bonus && top && best) {
354
+ if (bonus && top && first) {
335
355
  const { status } = bonus;
336
356
  const remaining = status.remaining ?? 0;
337
357
  const deadline = status.deadline ?? purchase.date;
338
358
  const daysLeft = status.daysLeft ?? 0;
339
- const costHere = top.cardId === best.cardId || top.earned === null || best.earned === null
359
+ const costHere = ties || top.earned === null || first.earned === null
340
360
  ? null
341
- : Math.round((best.earned - top.earned) * 100) / 100;
361
+ : roundCents(first.earned - top.earned);
342
362
  bonusResult = {
343
363
  cardId: top.cardId,
344
364
  checkoutLabel: top.checkoutLabel,
@@ -349,16 +369,16 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
349
369
  costHere,
350
370
  };
351
371
  const pace = daysLeft >= 7
352
- ? ` (about ${money(Math.round(remaining / (daysLeft / 7)))} a week)`
372
+ ? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
353
373
  : '';
354
- const worth = status.value === null
355
- ? ''
356
- : `, worth about ${money(Math.round(status.value))}`;
357
- notes.unshift(`You're working on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${top.cardId === best.cardId
358
- ? ''
359
- : costHere === null
360
- ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(best.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
361
- : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
374
+ const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
375
+ notes.unshift(ties
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}.`
377
+ : `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
378
+ ? ''
379
+ : costHere === null
380
+ ? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
381
+ : ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
362
382
  }
363
383
  let confidence = merchant.confidence;
364
384
  if (confidence === 'high' && chosen && top) {
@@ -386,17 +406,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
386
406
  bonus: bonusResult,
387
407
  };
388
408
  }
409
+ // Behind a steady pace: less spent than an even share of the spending needed
410
+ // for the days gone by. Ahead of it, the bonus will likely be met anyway, so
411
+ // it doesn't justify a card that earns less.
412
+ function behindPace(status, date) {
413
+ if (status.spend === null || status.deadline === null)
414
+ return false;
415
+ const window = Math.max(1, daysBetween(status.opened, status.deadline));
416
+ const elapsed = Math.min(window, daysBetween(status.opened, date));
417
+ return status.spent < (status.spend * elapsed) / window;
418
+ }
389
419
  // The open welcome bonus that most needs this purchase: the one with the most
390
- // spend still needed per day left.
420
+ // spend still needed per day left. Only a bonus behind pace takes the pick
421
+ // (`behind`); one on pace is only mentioned.
391
422
  function bonusPick(catalog, wallet, entries, date) {
392
423
  let pick = null;
393
424
  for (const entry of entries) {
394
425
  const status = bonusStatus(catalog, wallet, entry.held, date);
395
426
  if (status?.state !== 'open' || date < status.opened)
396
427
  continue;
428
+ const behind = behindPace(status, date);
397
429
  const pace = (status.remaining ?? 0) / Math.max(1, status.daysLeft ?? 1);
398
- if (!pick || pace > pick.pace)
399
- pick = { entry, status, pace };
430
+ if (!pick ||
431
+ (behind && !pick.behind) ||
432
+ (behind === pick.behind && pace > pick.pace))
433
+ pick = { entry, status, behind, pace };
400
434
  }
401
435
  return pick;
402
436
  }
@@ -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.
@@ -45,7 +46,7 @@ function matcher(card) {
45
46
  (token.length >= 5 &&
46
47
  haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
47
48
  }
48
- function needsAnniversary(card) {
49
+ export function needsAnniversary(card) {
49
50
  return (card.caps.some((c) => c.period === 'cardmember_year') ||
50
51
  card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
51
52
  }
@@ -62,10 +63,18 @@ export function membershipsUsed(card) {
62
63
  ...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
63
64
  ];
64
65
  }
66
+ // Memberships that matter to a card: ones its rates need, and one the card
67
+ // itself needs (a Costco membership for Costco Anywhere).
68
+ export function membershipsFor(card) {
69
+ return [
70
+ ...membershipsUsed(card),
71
+ ...(card.requires_membership ? [card.requires_membership] : []),
72
+ ];
73
+ }
65
74
  export function knownMemberships(catalog) {
66
- return [...new Set(catalog.cards.flatMap(membershipsUsed))].sort();
75
+ return catalog.memberships.map((m) => m.id).sort();
67
76
  }
68
- export function cardInfo(card, wallet, today) {
77
+ export function cardInfo(catalog, card, wallet, today) {
69
78
  return {
70
79
  id: card.id,
71
80
  name: card.name,
@@ -84,6 +93,13 @@ export function cardInfo(card, wallet, today) {
84
93
  default: c.default ?? null,
85
94
  })),
86
95
  memberships: membershipsUsed(card),
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
+ },
87
103
  activation: activationRules(card, today),
88
104
  needsAnniversary: needsAnniversary(card),
89
105
  };
@@ -111,7 +127,7 @@ export function searchCards(catalog, query, wallet, today) {
111
127
  .sort(byShortestId)
112
128
  .slice(0, 5);
113
129
  return {
114
- cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
130
+ cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
115
131
  exact,
116
132
  };
117
133
  }
@@ -137,7 +153,8 @@ export function setupNotes(catalog, wallet, held, today) {
137
153
  : `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
138
154
  });
139
155
  }
140
- const missing = membershipsUsed(card).filter((m) => !wallet.memberships.includes(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)));
141
158
  if (missing.length > 0) {
142
159
  notes.push({
143
160
  kind: 'membership',
@@ -1,12 +1,13 @@
1
1
  import { resolveMerchant } from "../merchants/resolve.js";
2
2
  import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
3
3
  import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
4
+ import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
4
5
  import { choicePeriod, periodKey } from "./periods.js";
5
6
  import { applyBooking, capBooking } from "./record.js";
6
7
  import { rotationGap } from "./rotation.js";
7
- import { roundCents } from "./text.js";
8
+ import { money, roundCents } from "./text.js";
8
9
  import { cardValuation } from "./valuation.js";
9
- const EPSILON = 1e-9;
10
+ import { EPSILON } from "./compare.js";
10
11
  const MONTHS = 12;
11
12
  const SHORTLIST = 8; // best single cards considered for pairs
12
13
  const PER_LINE = 2; // plus the best cards for each spending line
@@ -44,7 +45,10 @@ function withCurrentPicks(card, held, date) {
44
45
  // Spends `lines` every month for a year. Each dollar goes to the card that
45
46
  // earns the most on it right then, so spend moves to another card once a
46
47
  // cap runs out.
47
- export function simulateWallet(catalog, wallet, lines, start) {
48
+ export function simulateWallet(catalog, wallet, lines, start,
49
+ // Cards the person already has: their first year is behind them and any
50
+ // membership they need is paid for.
51
+ held = []) {
48
52
  const cards = wallet.cards.map((held) => {
49
53
  const card = catalog.cards.find((c) => c.id === held.id);
50
54
  if (!card)
@@ -120,7 +124,16 @@ export function simulateWallet(catalog, wallet, lines, start) {
120
124
  });
121
125
  const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
122
126
  const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
127
+ const paid = paidMemberships(catalog, {
128
+ memberships: wallet.memberships,
129
+ heldCards: held,
130
+ merchantIds: lines.map((l) => l.merchant.merchantId),
131
+ });
132
+ const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
133
+ const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
123
134
  const notes = [];
135
+ for (const m of memberships)
136
+ notes.push(membershipNote(catalog, m));
124
137
  const dates = monthDates(start);
125
138
  for (const { card } of cards) {
126
139
  const gaps = [
@@ -130,9 +143,10 @@ export function simulateWallet(catalog, wallet, lines, start) {
130
143
  notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
131
144
  if (card.annual_fee === undefined)
132
145
  notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
133
- else if (card.first_year_fee !== undefined &&
146
+ else if (!held.includes(card.id) &&
147
+ card.first_year_fee !== undefined &&
134
148
  card.first_year_fee !== card.annual_fee)
135
- 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.`);
136
150
  }
137
151
  return {
138
152
  cards: wallet.cards.map((h) => ({
@@ -142,7 +156,9 @@ export function simulateWallet(catalog, wallet, lines, start) {
142
156
  spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
143
157
  rewards,
144
158
  annualFees,
145
- net: roundCents(rewards - annualFees),
159
+ memberships,
160
+ membershipFees,
161
+ net: roundCents(rewards - annualFees - membershipFees),
146
162
  lines: lineResults,
147
163
  notes,
148
164
  };
@@ -192,7 +208,7 @@ function walletWith(base, card, picks, options) {
192
208
  function bestSimulation(catalog, base, card, lines, options) {
193
209
  let best = null;
194
210
  for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
195
- const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
211
+ const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start, options.held);
196
212
  if (!best || sim.net > best.net + EPSILON)
197
213
  best = sim;
198
214
  }
@@ -200,7 +216,8 @@ function bestSimulation(catalog, base, card, lines, options) {
200
216
  throw new Error(`no simulation for ${card.id}`);
201
217
  return best;
202
218
  }
203
- const byNet = (a, b) => b.net - a.net || a.annualFees - b.annualFees;
219
+ const costs = (s) => s.annualFees + s.membershipFees;
220
+ const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
204
221
  // Pairs that tie and share their first card differ only in an
205
222
  // interchangeable second card (several flat 2% cards), so they fold into one.
206
223
  function foldTies(pairs) {
@@ -208,7 +225,7 @@ function foldTies(pairs) {
208
225
  for (const pair of pairs) {
209
226
  const [a, b] = pair.cards;
210
227
  const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
211
- f.annualFees === pair.annualFees &&
228
+ costs(f) === costs(pair) &&
212
229
  f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
213
230
  if (!twin) {
214
231
  folded.push({ ...pair, alternatives: [] });
@@ -275,8 +292,9 @@ function projectedWallet(wallet, catalog, start) {
275
292
  }
276
293
  export function simulateAdditions(catalog, wallet, lines, options) {
277
294
  const projected = projectedWallet(wallet, catalog, options.start);
278
- const current = simulateWallet(catalog, projected, lines, options.start);
279
- const held = new Set(wallet.cards.map((c) => c.id));
295
+ const heldIds = wallet.cards.map((c) => c.id);
296
+ const current = simulateWallet(catalog, projected, lines, options.start, heldIds);
297
+ const held = new Set(heldIds);
280
298
  const additions = [];
281
299
  for (const card of catalog.cards) {
282
300
  if (held.has(card.id) || !matchesFilters(card, options.filters))
@@ -285,19 +303,23 @@ export function simulateAdditions(catalog, wallet, lines, options) {
285
303
  ...options,
286
304
  memberships: wallet.memberships,
287
305
  valuations: wallet.valuations,
306
+ held: heldIds,
288
307
  });
289
308
  const extraRewards = roundCents(simulation.rewards - current.rewards);
290
309
  const annualFee = card.annual_fee ?? 0;
310
+ const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
291
311
  additions.push({
292
312
  cardId: card.id,
293
313
  picks: simulation.cards.at(-1)?.picks ?? {},
294
314
  extraRewards,
295
315
  annualFee,
296
- gain: roundCents(extraRewards - annualFee),
316
+ membershipFee,
317
+ gain: roundCents(extraRewards - annualFee - membershipFee),
297
318
  simulation,
298
319
  });
299
320
  }
300
- additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
321
+ additions.sort((a, b) => b.gain - a.gain ||
322
+ a.annualFee + a.membershipFee - (b.annualFee + b.membershipFee));
301
323
  return { current, additions };
302
324
  }
303
325
  // Rough monthly amounts for a U.S. household: the starting point when someone
@@ -320,4 +342,4 @@ export function spendLineFor(catalog, what, monthly) {
320
342
  export function typicalSpendLines(catalog) {
321
343
  return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
322
344
  }
323
- export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} $${String(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
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.`;
@@ -2,8 +2,24 @@ import { singleMcc } from "./evaluate.js";
2
2
  export const num = (n) => String(Number(n.toFixed(2)));
3
3
  export const pct = (n) => `${num(n)}%`;
4
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 })}`;
5
+ // An id in words: "gas-ev-charging" reads "gas ev charging".
6
+ export const words = (id) => id.replace(/[-_.]/g, ' ');
7
+ const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
8
+ ...options,
9
+ timeZone: 'UTC',
10
+ });
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 })}`;
7
23
  export function feeText(fees) {
8
24
  const { annualFee, firstYearFee } = fees;
9
25
  if (annualFee === null)
@@ -51,3 +67,52 @@ export function pointValuesText(catalog, shown) {
51
67
  const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
52
68
  return `Values points at: ${list.join(', ')}.`;
53
69
  }
70
+ export const NETWORK_NAMES = {
71
+ visa: 'Visa',
72
+ mastercard: 'Mastercard',
73
+ amex: 'American Express',
74
+ discover: 'Discover',
75
+ store: 'store card',
76
+ };
77
+ // A name with its own "and" ("drugstores and pharmacies") would run into the
78
+ // list's: those lists use commas only, with those names last.
79
+ export const listed = (parts, joiner = 'and') => {
80
+ const anded = parts.filter((p) => p.includes(' and '));
81
+ if (anded.length > 0)
82
+ return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
83
+ return parts.length <= 2
84
+ ? parts.join(` ${joiner} `)
85
+ : `${parts.slice(0, -1).join(', ')} ${joiner} ${parts.at(-1) ?? ''}`;
86
+ };
87
+ const NUMBERS = {
88
+ one: '1',
89
+ two: '2',
90
+ three: '3',
91
+ four: '4',
92
+ five: '5',
93
+ six: '6',
94
+ };
95
+ // "five-percent" reads "5%"; other ids read as words.
96
+ export function choiceName(id) {
97
+ return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
98
+ }
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',
118
+ };
@@ -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}'`);
@@ -1,3 +1,5 @@
1
+ import { WALLET_VERSION, } from "../schema/wallet.js";
2
+ import { addMonths } from "./bonus.js";
1
3
  import { capByKey } from "./evaluate.js";
2
4
  import { choicePeriod, periodKey } from "./periods.js";
3
5
  import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
@@ -132,11 +134,25 @@ export function cardFields(card, held, input, today) {
132
134
  problems.push(`"${b.by}" isn't a deadline after the opening date.`);
133
135
  if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
134
136
  problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
137
+ // The terms are saved with the bonus, so a later change to the published
138
+ // offer, or a wallet exported and imported again, keeps the user's own.
139
+ const offer = card.welcome_offer;
140
+ const before = held?.bonus?.opened === b.opened ? held.bonus : undefined;
141
+ const spend = b.spend ?? before?.spend ?? offer?.spend;
142
+ const by = b.by ??
143
+ before?.by ??
144
+ (offer && isDate(b.opened)
145
+ ? addMonths(b.opened, offer.months)
146
+ : undefined);
147
+ const points = before?.points ?? (before?.dollars ? undefined : offer?.points);
148
+ const dollars = before?.dollars ?? (points !== undefined ? undefined : offer?.dollars);
135
149
  fields.bonus = {
136
150
  opened: b.opened,
137
151
  spent: b.spent ?? held?.bonus?.spent ?? 0,
138
- ...(b.spend === undefined ? {} : { spend: b.spend }),
139
- ...(b.by === undefined ? {} : { by: b.by }),
152
+ ...(spend === undefined ? {} : { spend }),
153
+ ...(by === undefined ? {} : { by }),
154
+ ...(points === undefined ? {} : { points }),
155
+ ...(dollars === undefined ? {} : { dollars }),
140
156
  };
141
157
  }
142
158
  return problems.length > 0 ? problems.join('\n') : fields;
@@ -173,7 +189,12 @@ export function addToWallet(catalog, wallet, input, today) {
173
189
  const held = withFields(newHolding(card.id), fields);
174
190
  const next = wallet
175
191
  ? { ...wallet, cards: [...wallet.cards, held] }
176
- : { valuations: {}, memberships: [], cards: [held] };
192
+ : {
193
+ version: WALLET_VERSION,
194
+ valuations: {},
195
+ memberships: [],
196
+ cards: [held],
197
+ };
177
198
  return {
178
199
  ok: true,
179
200
  wallet: next,
@@ -221,7 +242,7 @@ export function withMemberships(catalog, wallet, memberships) {
221
242
  const known = knownMemberships(catalog);
222
243
  const unknown = memberships.filter((m) => !known.includes(m));
223
244
  if (unknown.length > 0) {
224
- return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
245
+ return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
225
246
  }
226
247
  return {
227
248
  ok: true,
@@ -230,3 +251,20 @@ export function withMemberships(catalog, wallet, memberships) {
230
251
  notes: [],
231
252
  };
232
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;