earnrate 0.2.13 → 0.2.15

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 (50) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +28 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
  5. package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
  6. package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
  7. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  8. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  9. package/data/cards/discover-it-cash-back.yaml +9 -6
  10. package/data/cards/venmo-credit-card.yaml +1 -1
  11. package/data/categories.yaml +10 -0
  12. package/data/merchants/seed.yaml +3 -1
  13. package/dist/cli/import.js +14 -11
  14. package/dist/cli/todo.js +3 -2
  15. package/dist/cli/wallet.js +50 -17
  16. package/dist/engine/cheat-sheet.js +15 -11
  17. package/dist/engine/checklist.js +23 -25
  18. package/dist/engine/credits.js +2 -2
  19. package/dist/engine/evaluate.js +1 -1
  20. package/dist/engine/explore.js +4 -4
  21. package/dist/engine/intro.js +1 -5
  22. package/dist/engine/memberships.js +2 -6
  23. package/dist/engine/offers.js +3 -5
  24. package/dist/engine/periods.js +5 -0
  25. package/dist/engine/profiles.js +2 -1
  26. package/dist/engine/recommend.js +46 -33
  27. package/dist/engine/record.js +2 -1
  28. package/dist/engine/rotation.js +2 -1
  29. package/dist/engine/rule-text.js +98 -33
  30. package/dist/engine/setup.js +7 -3
  31. package/dist/engine/simulate.js +4 -4
  32. package/dist/engine/text.js +29 -8
  33. package/dist/engine/wallet-change.js +5 -5
  34. package/dist/engine/wallet-summary.js +24 -21
  35. package/dist/format/card-text.js +2 -3
  36. package/dist/format/cards.js +7 -3
  37. package/dist/format/explore.js +2 -2
  38. package/dist/format/wallet-yaml.js +13 -17
  39. package/dist/format/wallet.js +14 -10
  40. package/dist/import/analyze.js +13 -1
  41. package/dist/mcp/context.js +2 -3
  42. package/dist/mcp/inputs.js +3 -3
  43. package/dist/mcp/schemas.js +2 -0
  44. package/dist/mcp/server.js +0 -2
  45. package/dist/mcp/tools/account.js +1 -1
  46. package/dist/mcp/tools/answers.js +10 -5
  47. package/dist/mcp/tools/wallet.js +10 -6
  48. package/dist/schema/card.js +6 -10
  49. package/dist/schema/merchant.js +3 -0
  50. package/package.json +2 -1
@@ -19,12 +19,18 @@ const CARD_OPTIONS = `Options:
19
19
  card is ranked at its standard rates.
20
20
  --activated <rules> Rules you've activated with the issuer, comma
21
21
  separated. Replaces the current list.
22
- --enrolled <credits> Merchant credits you've enrolled in with the
22
+ --enrolled <credits> Statement credits you've enrolled in with the
23
23
  issuer, comma separated, e.g. --enrolled lululemon.
24
24
  Replaces the current list.
25
25
  --credit-used <credit>=<dollars>
26
26
  How much of a credit you've received this period,
27
27
  e.g. --credit-used lululemon=40. Repeat for each.
28
+ --cap-spent <cap>=<dollars>
29
+ Spending toward a cap so far this period, e.g.
30
+ --cap-spent us-supermarkets=1200. Repeat for each.
31
+ --milestone-spent <bonus>=<dollars>
32
+ Spending this year toward a spend bonus, e.g.
33
+ --milestone-spent spend-15k=9000. Repeat for each.
28
34
  --bonus-opened <date> Track a welcome bonus: the day the account opened
29
35
  (YYYY-MM-DD). Spending needed and the deadline
30
36
  default to the card's published offer.
@@ -53,15 +59,17 @@ ${CARD_OPTIONS}
53
59
  update: `Usage: ${CLI_NAME} update <card-id> [options]
54
60
 
55
61
  Changes a card in your wallet: picks for this period, label, anniversary,
56
- opening date, activations, merchant credits or welcome bonus. Picks for the
57
- current period are merged with ones already set.
62
+ opening date, activations, spending toward caps and spend bonuses, statement
63
+ credits or welcome bonus. Picks for the current period are merged with ones
64
+ already set.
58
65
 
59
66
  ${CARD_OPTIONS}
60
67
  `,
61
68
  remove: `Usage: ${CLI_NAME} remove <card-id>
62
69
 
63
- Removes a card from your wallet, with its tracked cap usage and bonus
64
- progress. Your memberships and point values stay.
70
+ Removes a card from your wallet, with its tracked cap usage, welcome-bonus
71
+ progress, spend-bonus spending and statement credits used. Your memberships
72
+ and point values stay.
65
73
  `,
66
74
  memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
67
75
 
@@ -143,6 +151,8 @@ export async function runWalletCommand(command, argv, io) {
143
151
  activated: { type: 'string' },
144
152
  enrolled: { type: 'string' },
145
153
  'credit-used': { type: 'string', multiple: true },
154
+ 'cap-spent': { type: 'string', multiple: true },
155
+ 'milestone-spent': { type: 'string', multiple: true },
146
156
  'bonus-opened': { type: 'string' },
147
157
  'bonus-spent': { type: 'string' },
148
158
  'bonus-spend': { type: 'string' },
@@ -173,7 +183,14 @@ export async function runWalletCommand(command, argv, io) {
173
183
  values.opened !== undefined ||
174
184
  values.activated !== undefined ||
175
185
  values.enrolled !== undefined ||
176
- values['credit-used'] !== undefined;
186
+ values['credit-used'] !== undefined ||
187
+ values['cap-spent'] !== undefined ||
188
+ values['milestone-spent'] !== undefined ||
189
+ values['bonus-opened'] !== undefined ||
190
+ values['bonus-spent'] !== undefined ||
191
+ values['bonus-spend'] !== undefined ||
192
+ values['bonus-by'] !== undefined ||
193
+ values['no-bonus'] !== undefined;
177
194
  if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
178
195
  return usage(`${CLI_NAME} ${command} doesn't take card options.`);
179
196
  }
@@ -192,7 +209,7 @@ export async function runWalletCommand(command, argv, io) {
192
209
  return RUNTIME_ERROR;
193
210
  }
194
211
  const header = result.exact ? '' : 'No card matches every word. Closest:\n';
195
- io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
212
+ io.stdout(`${header}${result.cards.map((c) => formatCardInfo(catalog, c)).join('\n')}\n`);
196
213
  return 0;
197
214
  }
198
215
  if (command === 'values') {
@@ -277,17 +294,33 @@ export async function runWalletCommand(command, argv, io) {
277
294
  .map((r) => r.trim())
278
295
  .filter((r) => r !== '');
279
296
  }
280
- if (values['credit-used'] !== undefined) {
281
- const used = {};
282
- for (const pair of values['credit-used']) {
283
- const [credit = '', dollars] = pair.split('=');
284
- const n = dollarsFlag('credit-used', dollars);
285
- if (credit.trim() === '' || n === undefined || typeof n === 'string')
286
- return usage('--credit-used takes <credit>=<dollars>, like lululemon=40.');
287
- used[credit.trim()] = n;
297
+ // --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
298
+ const dollarPairs = (flag, example) => {
299
+ const pairs = values[flag];
300
+ if (pairs === undefined)
301
+ return undefined;
302
+ const out = {};
303
+ for (const pair of pairs) {
304
+ const [id = '', dollars] = pair.split('=');
305
+ const n = dollarsFlag(flag, dollars);
306
+ if (id.trim() === '' || n === undefined || typeof n === 'string')
307
+ return `--${flag} takes <id>=<dollars>, like ${example}.`;
308
+ out[id.trim()] = n;
288
309
  }
289
- input.creditUsed = used;
290
- }
310
+ return out;
311
+ };
312
+ const creditUsed = dollarPairs('credit-used', 'lululemon=40');
313
+ const capSpent = dollarPairs('cap-spent', 'us-supermarkets=1200');
314
+ const milestoneSpent = dollarPairs('milestone-spent', 'spend-15k=9000');
315
+ for (const result of [creditUsed, capSpent, milestoneSpent])
316
+ if (typeof result === 'string')
317
+ return usage(result);
318
+ if (typeof creditUsed === 'object')
319
+ input.creditUsed = creditUsed;
320
+ if (typeof capSpent === 'object')
321
+ input.capSpent = capSpent;
322
+ if (typeof milestoneSpent === 'object')
323
+ input.milestoneSpent = milestoneSpent;
291
324
  if (values['no-bonus']) {
292
325
  input.bonus = null;
293
326
  }
@@ -6,7 +6,8 @@ import { periodKey } from "./periods.js";
6
6
  import { ABROAD, recommend } from "./recommend.js";
7
7
  import { checklist } from "./checklist.js";
8
8
  import { setupNotes } from "./setup.js";
9
- import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, } from "./text.js";
9
+ import { capitalize, CREDITS_LEFT_OUT, feeText, heldName, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
10
+ const midSentence = (item) => item.phrase ?? ('merchant' in item ? item.label : item.label.toLowerCase());
10
11
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
11
12
  // quick picks.
12
13
  export const EVERYDAY_PURCHASES = [
@@ -16,7 +17,11 @@ export const EVERYDAY_PURCHASES = [
16
17
  { label: 'Amazon', merchant: 'amazon' },
17
18
  { label: 'Costco', merchant: 'costco' },
18
19
  { label: 'Travel', category: 'travel' },
19
- { label: 'Streaming (Netflix)', merchant: 'netflix' },
20
+ {
21
+ label: 'Streaming (Netflix)',
22
+ merchant: 'netflix',
23
+ phrase: 'streaming (Netflix)',
24
+ },
20
25
  { label: 'Drugstores', category: 'drugstore' },
21
26
  { label: 'Everything else', category: 'other' },
22
27
  ];
@@ -43,7 +48,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
43
48
  // The card that earns the most, even when a welcome bonus would take the
44
49
  // pick for one purchase: the sheet is for the whole quarter.
45
50
  const top = rec.decision ? rec.ranked[0] : undefined;
46
- ranked.push({ label: item.label, cards: rec.ranked });
51
+ ranked.push({ phrase: midSentence(item), cards: rec.ranked });
47
52
  return {
48
53
  label: item.label,
49
54
  cardId: top?.cardId ?? null,
@@ -58,22 +63,20 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
58
63
  const status = bonusStatus(catalog, wallet, held, date);
59
64
  if (status?.state !== 'open')
60
65
  return [];
61
- const name = held.checkout_label ??
62
- catalog.cards.find((c) => c.id === held.id)?.name ??
63
- held.id;
66
+ const name = heldName(catalog, held);
64
67
  // Rows where the bonus card matches the row's card, or is close to it
65
68
  // (as single answers mention it).
66
69
  const matches = [];
67
70
  const near = [];
68
- for (const { label, cards } of ranked) {
71
+ for (const { phrase, cards } of ranked) {
69
72
  const best = cards[0]?.effectiveReturnPct;
70
73
  const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
71
74
  if (best === undefined || own === undefined)
72
75
  continue;
73
76
  if (best - own < EPSILON)
74
- matches.push(label.toLowerCase());
77
+ matches.push(phrase);
75
78
  else if (isClose(own, best))
76
- near.push(label.toLowerCase());
79
+ near.push(phrase);
77
80
  }
78
81
  const where = [
79
82
  ...(matches.length > 0
@@ -82,7 +85,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
82
85
  ...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
83
86
  ];
84
87
  return [
85
- `Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
88
+ `Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
86
89
  ? `It ${where.join(' and ')}, so those are the places to use it.`
87
90
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
88
91
  ];
@@ -130,7 +133,8 @@ function abroadRow(catalog, wallet, date) {
130
133
  country: ABROAD,
131
134
  }, merchant, { credits: false });
132
135
  const top = rec.decision ? rec.ranked[0] : undefined;
133
- const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
136
+ const knownFee = (id) => catalog.cards.find((c) => c.id === id)?.foreign_transaction_fee ?? 0;
137
+ const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => knownFee(r.cardId) > 0);
134
138
  return {
135
139
  label: ABROAD_LABEL,
136
140
  cardId: top?.cardId ?? null,
@@ -1,5 +1,5 @@
1
- import { currentPeriodEnd, daysBetween } from "./periods.js";
2
- import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, } from "./text.js";
1
+ import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
2
+ import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
3
3
  import { milestonePays } from "./milestones.js";
4
4
  import { optionName } from "./rule-text.js";
5
5
  import { activationsDue, walletSummary, } from "./wallet-summary.js";
@@ -24,7 +24,7 @@ shortName) {
24
24
  items.push({
25
25
  kind: 'activate',
26
26
  cardId: card.id,
27
- text: `Activate ${name}'s bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
27
+ text: `Activate ${possessive(name)} bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
28
28
  due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
29
29
  });
30
30
  }
@@ -41,7 +41,7 @@ shortName) {
41
41
  items.push({
42
42
  kind: 'pick',
43
43
  cardId: card.id,
44
- text: `Pick ${name}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
44
+ text: `Pick ${possessive(name)} ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
45
45
  due,
46
46
  });
47
47
  }
@@ -56,7 +56,7 @@ shortName) {
56
56
  items.push({
57
57
  kind: 'pick',
58
58
  cardId: card.id,
59
- text: `Pick ${name}'s categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
59
+ text: `Pick ${possessive(name)} categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
60
60
  due: end,
61
61
  });
62
62
  }
@@ -66,7 +66,7 @@ shortName) {
66
66
  items.push({
67
67
  kind: 'pick',
68
68
  cardId: card.id,
69
- text: `${name}'s ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
69
+ text: `${possessive(name)} ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
70
70
  due: null,
71
71
  });
72
72
  for (const cap of s.caps) {
@@ -78,7 +78,7 @@ shortName) {
78
78
  kind: 'cap',
79
79
  cardId: card.id,
80
80
  text: cap.remaining === 0
81
- ? `${name}'s ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
81
+ ? `${possessive(name)} ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
82
82
  : `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
83
83
  due: resets,
84
84
  });
@@ -94,18 +94,21 @@ shortName) {
94
94
  due: m.until,
95
95
  });
96
96
  }
97
+ const toEnroll = s.credits.filter((c) => !c.enrolled);
98
+ if (toEnroll.length > 0)
99
+ items.push({
100
+ kind: 'enroll',
101
+ cardId: card.id,
102
+ ref: toEnroll.map((c) => c.id).join(','),
103
+ text: enrollCreditText(name, toEnroll.map((c) => c.name)),
104
+ due: null,
105
+ });
97
106
  for (const c of s.credits) {
98
107
  if (!c.enrolled)
99
- items.push({
100
- kind: 'enroll',
101
- cardId: card.id,
102
- ref: c.id,
103
- text: enrollCreditText(name, c.name),
104
- due: null,
105
- });
108
+ continue;
106
109
  // One that needs no enrolling shows once the cardholder uses it, so a
107
110
  // card's every credit isn't a to-do for someone who never shops there.
108
- else if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
111
+ if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
109
112
  items.push({
110
113
  kind: 'credit',
111
114
  cardId: card.id,
@@ -120,7 +123,7 @@ shortName) {
120
123
  items.push({
121
124
  kind: 'bonus',
122
125
  cardId: card.id,
123
- text: `${name}'s welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
126
+ text: `${possessive(name)} welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
124
127
  due: b.deadline,
125
128
  });
126
129
  }
@@ -139,7 +142,7 @@ shortName) {
139
142
  items.push({
140
143
  kind: 'rotation',
141
144
  cardId: card.id,
142
- text: `${name}'s ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
145
+ text: `${possessive(name)} ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
143
146
  due: null,
144
147
  });
145
148
  }
@@ -148,8 +151,8 @@ shortName) {
148
151
  a.cardId.localeCompare(b.cardId));
149
152
  }
150
153
  // An all-day calendar event for each dated action (activations, picks,
151
- // welcome-bonus deadlines, credits to use), with a reminder two days before. Built on the
152
- // device: nothing is sent anywhere.
154
+ // welcome-bonus deadlines, credits to use), with a reminder two days before.
155
+ // Built on the device: nothing is sent anywhere.
153
156
  export function checklistCalendar(items, stamp) {
154
157
  const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
155
158
  const fold = (line) => {
@@ -159,11 +162,6 @@ export function checklistCalendar(items, stamp) {
159
162
  return parts.join('\r\n ');
160
163
  };
161
164
  const day = (d) => d.replace(/-/g, '');
162
- const next = (d) => {
163
- const t = new Date(`${d}T00:00:00Z`);
164
- t.setUTCDate(t.getUTCDate() + 1);
165
- return t.toISOString().slice(0, 10);
166
- };
167
165
  const events = items
168
166
  .filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
169
167
  .flatMap((i) => {
@@ -173,7 +171,7 @@ export function checklistCalendar(items, stamp) {
173
171
  `UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
174
172
  `DTSTAMP:${stamp}`,
175
173
  `DTSTART;VALUE=DATE:${day(due)}`,
176
- `DTEND;VALUE=DATE:${day(next(due))}`,
174
+ `DTEND;VALUE=DATE:${day(addDays(due, 1))}`,
177
175
  fold(`SUMMARY:${escape(i.text)}`),
178
176
  'BEGIN:VALARM',
179
177
  'TRIGGER:-P2D',
@@ -15,8 +15,8 @@ export function creditCovers(catalog, credit, merchant) {
15
15
  return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
16
16
  }
17
17
  // A credit that needs enrolling counts only once the wallet says it's done.
18
- export const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
- export function creditState(catalog, credit, held, date) {
18
+ const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
+ function creditState(catalog, credit, held, date) {
20
20
  const anniversary = held.anniversary ?? null;
21
21
  const period = periodKey(credit.period, date, anniversary);
22
22
  const usage = held.credit_usage?.[credit.id];
@@ -3,7 +3,7 @@ import { EPSILON } from "./compare.js";
3
3
  import { introCovers, introWindows, withIntro, } from "./intro.js";
4
4
  import { inEffect, periodKey } from "./periods.js";
5
5
  // PayPal checkout happens online, so paying through it says the channel.
6
- export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
6
+ const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
7
7
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
8
8
  const payment = purchase.payment ?? null;
9
9
  return {
@@ -4,11 +4,11 @@ import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
4
4
  import { creditName } from "./rule-text.js";
5
5
  import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
6
6
  import { choicePeriod, periodKey } from "./periods.js";
7
- import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
7
+ import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./recommend.js";
8
8
  import { rotationGap } from "./rotation.js";
9
9
  import { membershipsUsed } from "./setup.js";
10
10
  import { optionName } from "./rule-text.js";
11
- import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
11
+ import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, capSentence, } from "./text.js";
12
12
  import { cardValuation } from "./valuation.js";
13
13
  import { EPSILON } from "./compare.js";
14
14
  export const ALL_PERSONAL_CARDS = {
@@ -117,7 +117,7 @@ function conditionsText(catalog, card, r) {
117
117
  if (r.membership !== null)
118
118
  conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
119
119
  if (r.cap) {
120
- conditions.push(`Up to ${money(r.cap.amount)} ${CAP_PERIODS[r.cap.period]} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
120
+ conditions.push(`${capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency))}.`);
121
121
  }
122
122
  return conditions;
123
123
  }
@@ -131,7 +131,7 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
131
131
  memberships: [...options.memberships, membership],
132
132
  });
133
133
  const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
134
- const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
134
+ const fee = foreignFeeFor(card, purchase);
135
135
  const pct = evaluateCard(card, held, ctx).best.rate *
136
136
  cardValuation(catalog, wallet, card).centsPerUnit -
137
137
  fee;
@@ -1,8 +1,4 @@
1
- export function addDays(date, days) {
2
- const t = new Date(`${date}T00:00:00Z`);
3
- t.setUTCDate(t.getUTCDate() + days);
4
- return t.toISOString().slice(0, 10);
5
- }
1
+ import { addDays } from "./periods.js";
6
2
  // Never assumed: a card without a date is ranked at its standard rates.
7
3
  export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
8
4
  // The opening day is day 1, so 365 days run through the day before the first
@@ -1,14 +1,10 @@
1
- import { money, words } from "./text.js";
1
+ import { listed, money, withArticle, words } from "./text.js";
2
2
  export function membership(catalog, id) {
3
3
  return catalog.memberships.find((m) => m.id === id);
4
4
  }
5
5
  export function membershipName(catalog, id) {
6
6
  return membership(catalog, id)?.name ?? words(id);
7
7
  }
8
- // "a Costco membership", for a sentence.
9
- function withArticle(name) {
10
- return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
11
- }
12
8
  // Tiers of one membership: holding one, the others in its group aren't
13
9
  // missing (BofA Preferred Rewards).
14
10
  export function sameGroup(catalog, a, b) {
@@ -67,5 +63,5 @@ export function membershipNeeded(catalog, card, paid) {
67
63
  }
68
64
  export function membershipNote(catalog, m) {
69
65
  const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
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.`;
66
+ return `${listed(names)} ${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.`;
71
67
  }
@@ -2,17 +2,15 @@ import { bonusDollars } from "./bonus.js";
2
2
  import { matchesFilters } from "./explore.js";
3
3
  import { membershipCosts, paidMemberships } from "./memberships.js";
4
4
  import { inEffect } from "./periods.js";
5
- import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
5
+ import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
6
6
  import { cardValuation } from "./valuation.js";
7
7
  function ineligibleText(catalog, wallet, notWith) {
8
8
  const held = wallet.cards
9
9
  .filter((c) => notWith.includes(c.id))
10
- .map((c) => c.checkout_label ??
11
- catalog.cards.find((k) => k.id === c.id)?.name ??
12
- c.id);
10
+ .map((c) => heldName(catalog, c));
13
11
  return held.length === 0
14
12
  ? null
15
- : `Likely not eligible: you hold ${held.join(' and ')}, and the issuer's terms rule out its cardholders.`;
13
+ : `Likely not eligible: you hold ${listed(held)}, and the issuer's terms rule out its cardholders.`;
16
14
  }
17
15
  export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
18
16
  // "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
@@ -23,6 +23,11 @@ export function periodKey(period, date, anniversary) {
23
23
  export function daysBetween(from, to) {
24
24
  return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
25
25
  }
26
+ export function addDays(date, days) {
27
+ const t = new Date(`${date}T00:00:00Z`);
28
+ t.setUTCDate(t.getUTCDate() + days);
29
+ return t.toISOString().slice(0, 10);
30
+ }
26
31
  // The user's local calendar date, not UTC: a purchase at 9pm in Los Angeles
27
32
  // belongs to that day.
28
33
  export function localToday(now = new Date()) {
@@ -1,3 +1,4 @@
1
+ import { listed } from "./text.js";
1
2
  export const VALUATION_PROFILES = {
2
3
  flat: { MR: 1.0, UR: 1.0 },
3
4
  'points-heavy': { MR: 1.7, UR: 1.7 },
@@ -7,7 +8,7 @@ export function profileText(profile) {
7
8
  const values = Object.entries(VALUATION_PROFILES[profile]);
8
9
  const cents = [...new Set(values.map(([, v]) => v))];
9
10
  return cents.length === 1
10
- ? `${values.map(([id]) => id).join(' and ')} at ${String(cents[0])}¢`
11
+ ? `${listed(values.map(([id]) => id))} at ${String(cents[0])}¢`
11
12
  : values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
12
13
  }
13
14
  export function isValuationProfile(name) {