earnrate 0.2.12 → 0.2.14

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 (99) hide show
  1. package/README.md +20 -7
  2. package/data/CHANGELOG.md +197 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-everyday.yaml +28 -2
  5. package/data/cards/amex-blue-cash-preferred.yaml +19 -0
  6. package/data/cards/amex-business-gold.yaml +38 -3
  7. package/data/cards/amex-business-platinum.yaml +72 -3
  8. package/data/cards/amex-delta-skymiles-gold.yaml +34 -9
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +40 -9
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +42 -10
  11. package/data/cards/amex-gold.yaml +52 -0
  12. package/data/cards/amex-hilton-honors-aspire.yaml +23 -1
  13. package/data/cards/amex-hilton-honors-business.yaml +14 -1
  14. package/data/cards/amex-hilton-honors-surpass.yaml +14 -1
  15. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +15 -1
  16. package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
  17. package/data/cards/amex-platinum.yaml +110 -0
  18. package/data/cards/apple-card.yaml +76 -0
  19. package/data/cards/bilt-obsidian.yaml +12 -2
  20. package/data/cards/bilt-palladium.yaml +12 -2
  21. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  22. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  23. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  24. package/data/cards/capital-one-venture-x.yaml +30 -2
  25. package/data/cards/chase-aeroplan.yaml +14 -0
  26. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  27. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  28. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  29. package/data/cards/chase-ink-business-cash.yaml +13 -0
  30. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  31. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  32. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  33. package/data/cards/chase-united-business.yaml +32 -0
  34. package/data/cards/chase-united-club-business.yaml +40 -0
  35. package/data/cards/chase-united-club.yaml +40 -0
  36. package/data/cards/chase-united-explorer.yaml +32 -0
  37. package/data/cards/chase-united-quest.yaml +40 -0
  38. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  39. package/data/cards/citi-strata-elite.yaml +20 -1
  40. package/data/cards/discover-it-cash-back.yaml +8 -5
  41. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  42. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  43. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  44. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  45. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  46. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  47. package/data/cards/usbank-altitude-connect.yaml +8 -2
  48. package/data/cards/usbank-altitude-go.yaml +8 -2
  49. package/data/cards/usbank-smartly.yaml +107 -0
  50. package/data/cards/venmo-credit-card.yaml +151 -0
  51. package/data/cards/verizon-visa-card.yaml +122 -0
  52. package/data/currencies.yaml +14 -0
  53. package/data/issuers.yaml +22 -0
  54. package/data/memberships.yaml +35 -0
  55. package/data/merchants/overrides.yaml +448 -0
  56. package/data/merchants/seed.yaml +1085 -66
  57. package/dist/cli/args.js +18 -0
  58. package/dist/cli/explore.js +10 -1
  59. package/dist/cli/run.js +4 -1
  60. package/dist/cli/wallet.js +31 -4
  61. package/dist/data/file-store.js +5 -2
  62. package/dist/data/integrity.js +22 -0
  63. package/dist/data/store.js +4 -1
  64. package/dist/data/wallet-edit.js +25 -6
  65. package/dist/engine/cheat-sheet.js +20 -7
  66. package/dist/engine/checklist.js +35 -11
  67. package/dist/engine/credits.js +65 -0
  68. package/dist/engine/evaluate.js +38 -11
  69. package/dist/engine/explore.js +72 -9
  70. package/dist/engine/offers.js +2 -1
  71. package/dist/engine/periods.js +10 -3
  72. package/dist/engine/recommend.js +101 -38
  73. package/dist/engine/record.js +42 -1
  74. package/dist/engine/rotation.js +4 -0
  75. package/dist/engine/rule-text.js +35 -7
  76. package/dist/engine/setup.js +17 -6
  77. package/dist/engine/simulate.js +13 -4
  78. package/dist/engine/text.js +89 -1
  79. package/dist/engine/wallet-change.js +37 -1
  80. package/dist/engine/wallet-summary.js +32 -4
  81. package/dist/format/card-text.js +4 -3
  82. package/dist/format/cards.js +6 -2
  83. package/dist/format/cheat-sheet.js +2 -0
  84. package/dist/format/explore.js +20 -4
  85. package/dist/format/recommendation.js +6 -2
  86. package/dist/format/wallet-yaml.js +11 -15
  87. package/dist/format/wallet.js +15 -7
  88. package/dist/import/analyze.js +7 -2
  89. package/dist/mcp/inputs.js +10 -0
  90. package/dist/mcp/prompts.js +9 -1
  91. package/dist/mcp/schemas.js +71 -1
  92. package/dist/mcp/tools/answers.js +11 -5
  93. package/dist/mcp/tools/wallet.js +15 -7
  94. package/dist/merchants/resolve.js +8 -0
  95. package/dist/schema/card.js +37 -3
  96. package/dist/schema/common.js +1 -1
  97. package/dist/schema/merchant.js +6 -0
  98. package/dist/schema/wallet.js +8 -0
  99. package/package.json +1 -1
package/dist/cli/args.js CHANGED
@@ -2,6 +2,7 @@ import { parseArgs } from 'node:util';
2
2
  import { isValuationProfile, } from "../engine/profiles.js";
3
3
  import { knownMemberships } from "../engine/setup.js";
4
4
  import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
5
+ import { PaymentMethod } from "../schema/card.js";
5
6
  // Shared by every subcommand, so each flag means the same thing everywhere.
6
7
  // A string result is the error to show.
7
8
  const LOOKS_LIKE_AMOUNT = /^\$?[\d,]*\.?\d+$/;
@@ -50,6 +51,18 @@ export function membershipProblem(catalog, members) {
50
51
  ? null
51
52
  : `Unknown membership "${unknown.join('", "')}". Use one of: ${known.join(', ')}.`;
52
53
  }
54
+ export const PAYING_HELP = `--paying <how> card, apple-pay, google-pay or paypal. Rates that need
55
+ one (Apple Pay on Apple Card) count only when you say.`;
56
+ export function paymentOf(text) {
57
+ if (text === undefined)
58
+ return { payment: null };
59
+ const parsed = PaymentMethod.safeParse(text.toLowerCase());
60
+ return parsed.success
61
+ ? { payment: parsed.data }
62
+ : {
63
+ problem: `Unknown way of paying "${text}". Use one of: ${PaymentMethod.options.join(', ')}.`,
64
+ };
65
+ }
53
66
  export const BOTH_CHANNELS = 'Pick --online or --in-store, not both.';
54
67
  export function channelOf(values) {
55
68
  return values.online ? 'online' : values['in-store'] ? 'in_store' : null;
@@ -75,6 +88,7 @@ export function parseCliArgs(argv) {
75
88
  valuation: { type: 'string' },
76
89
  date: { type: 'string' },
77
90
  country: { type: 'string' },
91
+ paying: { type: 'string' },
78
92
  json: { type: 'boolean' },
79
93
  category: { type: 'string' },
80
94
  all: { type: 'boolean' },
@@ -112,6 +126,9 @@ export function parseCliArgs(argv) {
112
126
  const country = values.country ?? 'US';
113
127
  if (!COUNTRY_CODE.test(country))
114
128
  return { kind: 'error', message: countryProblem(country) };
129
+ const paying = paymentOf(values.paying);
130
+ if ('problem' in paying)
131
+ return { kind: 'error', message: paying.problem };
115
132
  return {
116
133
  kind: 'run',
117
134
  options: {
@@ -121,6 +138,7 @@ export function parseCliArgs(argv) {
121
138
  valuation,
122
139
  date: values.date ?? null,
123
140
  country: country.toUpperCase(),
141
+ payment: paying.payment,
124
142
  json: values.json ?? false,
125
143
  all: values.all ?? false,
126
144
  category: values.category ?? null,
@@ -5,7 +5,7 @@ import { ALL_PERSONAL_CARDS, exploreCards, } from "../engine/explore.js";
5
5
  import { isValuationProfile, profileText, VALUATION_PROFILES, withValuationProfile, } from "../engine/profiles.js";
6
6
  import { resolveSpendLines, simulateAdditions, simulateCatalog, spendLineFor, typicalSpend, TYPICAL_SPEND_NOTE, } from "../engine/simulate.js";
7
7
  import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../format/explore.js";
8
- import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, splitAmount, valuationProblem, } from "./args.js";
8
+ import { BOTH_CHANNELS, cardList, channelOf, dateProblem, membershipProblem, paymentOf, splitAmount, valuationProblem, } from "./args.js";
9
9
  import { COUNTRY_CODE, countryProblem } from "../schema/common.js";
10
10
  import { cardsToUse, RUNTIME_ERROR, savedSettings, USAGE_ERROR, } from "./run.js";
11
11
  const FILTER_OPTIONS = ` --cash, --points Cash back cards only, or points and miles cards only.
@@ -33,6 +33,8 @@ ${FILTER_OPTIONS}
33
33
  --date <YYYY-MM-DD> Purchase date. Default: today.
34
34
  --country <code> Where you're paying. Abroad, cards rank after their
35
35
  foreign transaction fees. Default: US.
36
+ --paying <how> card, apple-pay, google-pay or paypal. Rates that need
37
+ one (Apple Pay on Apple Card) count only when you say.
36
38
  --all List every card, not just the top 10.
37
39
  --json Print the full result as JSON.
38
40
  -h, --help Show this help.
@@ -108,6 +110,7 @@ export async function runBest(argv, io) {
108
110
  'in-store': { type: 'boolean' },
109
111
  date: { type: 'string' },
110
112
  country: { type: 'string' },
113
+ paying: { type: 'string' },
111
114
  },
112
115
  });
113
116
  }
@@ -144,6 +147,11 @@ export async function runBest(argv, io) {
144
147
  io.stderr(`${countryProblem(country)}\n`);
145
148
  return USAGE_ERROR;
146
149
  }
150
+ const paying = paymentOf(values.paying);
151
+ if ('problem' in paying) {
152
+ io.stderr(`${paying.problem}\n`);
153
+ return USAGE_ERROR;
154
+ }
147
155
  const catalog = await loadCatalog();
148
156
  const settings = settingsFrom(values, catalog, await savedSettings(io));
149
157
  if (typeof settings === 'string') {
@@ -157,6 +165,7 @@ export async function runBest(argv, io) {
157
165
  channel: channelOf(values),
158
166
  date: values.date ?? io.today,
159
167
  country: country.toUpperCase(),
168
+ payment: paying.payment,
160
169
  }, settings);
161
170
  io.stdout(values.json
162
171
  ? `${JSON.stringify(result, null, 2)}\n`
package/dist/cli/run.js CHANGED
@@ -17,7 +17,7 @@ import { llmEnabled, openLlmSession } from "../merchants/from-env.js";
17
17
  import { missedPath, missLogAt } from "../merchants/missed.js";
18
18
  import { resolvePlace, withCallerCategory } from "../merchants/resolve.js";
19
19
  import { packageVersion } from "../version.js";
20
- import { membershipProblem, parseCliArgs } from "./args.js";
20
+ import { membershipProblem, PAYING_HELP, parseCliArgs } from "./args.js";
21
21
  import { runCheatSheet } from "./cheatsheet.js";
22
22
  import { runMissed } from "./missed.js";
23
23
  import { runTodo } from "./todo.js";
@@ -67,6 +67,7 @@ Options:
67
67
  --date <YYYY-MM-DD> Purchase date. Default: today.
68
68
  --country <code> Where you're paying. Abroad, cards rank after their
69
69
  foreign transaction fees. Default: US.
70
+ ${PAYING_HELP}
70
71
  --cards <ids> Answer for these cards instead of your wallet, comma
71
72
  separated: --cards amex-gold,chase-prime-visa.
72
73
  --member <name> A membership you have, in place of the saved ones.
@@ -201,6 +202,7 @@ export async function run(argv, io) {
201
202
  channel: options.channel,
202
203
  date: options.date ?? io.today,
203
204
  country: options.country,
205
+ payment: options.payment,
204
206
  };
205
207
  if (options.category !== null &&
206
208
  !catalog.categories.some((c) => c.id === options.category)) {
@@ -239,6 +241,7 @@ export async function run(argv, io) {
239
241
  channel: purchase.channel,
240
242
  date: purchase.date,
241
243
  country: purchase.country,
244
+ payment: purchase.payment ?? null,
242
245
  }, {
243
246
  filters: ALL_PERSONAL_CARDS,
244
247
  memberships: options.members.length > 0 ? options.members : saved.memberships,
@@ -19,6 +19,12 @@ 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
23
+ issuer, comma separated, e.g. --enrolled lululemon.
24
+ Replaces the current list.
25
+ --credit-used <credit>=<dollars>
26
+ How much of a credit you've received this period,
27
+ e.g. --credit-used lululemon=40. Repeat for each.
22
28
  --bonus-opened <date> Track a welcome bonus: the day the account opened
23
29
  (YYYY-MM-DD). Spending needed and the deadline
24
30
  default to the card's published offer.
@@ -47,8 +53,8 @@ ${CARD_OPTIONS}
47
53
  update: `Usage: ${CLI_NAME} update <card-id> [options]
48
54
 
49
55
  Changes a card in your wallet: picks for this period, label, anniversary,
50
- opening date, activations or welcome bonus. Picks for the current period are merged with
51
- ones already set.
56
+ opening date, activations, merchant credits or welcome bonus. Picks for the
57
+ current period are merged with ones already set.
52
58
 
53
59
  ${CARD_OPTIONS}
54
60
  `,
@@ -135,6 +141,8 @@ export async function runWalletCommand(command, argv, io) {
135
141
  anniversary: { type: 'string' },
136
142
  opened: { type: 'string' },
137
143
  activated: { type: 'string' },
144
+ enrolled: { type: 'string' },
145
+ 'credit-used': { type: 'string', multiple: true },
138
146
  'bonus-opened': { type: 'string' },
139
147
  'bonus-spent': { type: 'string' },
140
148
  'bonus-spend': { type: 'string' },
@@ -163,7 +171,9 @@ export async function runWalletCommand(command, argv, io) {
163
171
  values.pick !== undefined ||
164
172
  values.anniversary !== undefined ||
165
173
  values.opened !== undefined ||
166
- values.activated !== undefined;
174
+ values.activated !== undefined ||
175
+ values.enrolled !== undefined ||
176
+ values['credit-used'] !== undefined;
167
177
  if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
168
178
  return usage(`${CLI_NAME} ${command} doesn't take card options.`);
169
179
  }
@@ -182,7 +192,7 @@ export async function runWalletCommand(command, argv, io) {
182
192
  return RUNTIME_ERROR;
183
193
  }
184
194
  const header = result.exact ? '' : 'No card matches every word. Closest:\n';
185
- io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
195
+ io.stdout(`${header}${result.cards.map((c) => formatCardInfo(catalog, c)).join('\n')}\n`);
186
196
  return 0;
187
197
  }
188
198
  if (command === 'values') {
@@ -261,6 +271,23 @@ export async function runWalletCommand(command, argv, io) {
261
271
  const n = Number(text.replace(/[$,]/g, ''));
262
272
  return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
263
273
  };
274
+ if (values.enrolled !== undefined) {
275
+ input.enrolled = values.enrolled
276
+ .split(',')
277
+ .map((r) => r.trim())
278
+ .filter((r) => r !== '');
279
+ }
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;
288
+ }
289
+ input.creditUsed = used;
290
+ }
264
291
  if (values['no-bonus']) {
265
292
  input.bonus = null;
266
293
  }
@@ -87,8 +87,11 @@ export class FileWalletStore {
87
87
  return failed(`${purchase.cardId} isn't in the wallet.`);
88
88
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
89
89
  try {
90
- if (booked.cap || booked.bonus || booked.milestones.length > 0)
91
- await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : [], booked.milestones);
90
+ if (booked.cap ||
91
+ booked.bonus ||
92
+ booked.milestones.length > 0 ||
93
+ booked.credit)
94
+ await writeProgress(this.where, booked.cap ? [booked.cap] : [], booked.bonus ? [booked.bonus] : [], booked.milestones, booked.credit ? [booked.credit] : []);
92
95
  }
93
96
  catch (e) {
94
97
  return failed(errorText(e));
@@ -114,6 +114,7 @@ function cardProblems(card, catalog, merchantIds) {
114
114
  ['choice', card.choices.map((c) => c.id)],
115
115
  ['milestone', card.milestones.map((m) => m.id)],
116
116
  ['intro rate', card.intro_rates.map((i) => i.id)],
117
+ ['credit', card.credits.map((c) => c.id)],
117
118
  ]) {
118
119
  for (const id of duplicates(ids))
119
120
  problems.push(`${where}: duplicate ${kind} id '${id}'`);
@@ -130,6 +131,16 @@ function cardProblems(card, catalog, merchantIds) {
130
131
  if (!card.rules.some((r) => r.id === id))
131
132
  problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
132
133
  }
134
+ for (const credit of card.credits) {
135
+ if (card.rules.some((r) => r.id === credit.id) ||
136
+ card.milestones.some((m) => m.id === credit.id) ||
137
+ card.intro_rates.some((i) => i.id === credit.id))
138
+ problems.push(`${where}: credit '${credit.id}' has a rule's id`);
139
+ for (const c of credit.match.categories ?? [])
140
+ checkCategory(c, `credit ${credit.id}`);
141
+ for (const m of credit.match.merchants ?? [])
142
+ checkMerchant(m, `credit ${credit.id}`);
143
+ }
133
144
  if (card.foreign_transaction_fee === null &&
134
145
  !card.todo.some((t) => /foreign transaction fee/i.test(t)))
135
146
  problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
@@ -147,6 +158,7 @@ function cardProblems(card, catalog, merchantIds) {
147
158
  ...card.rules.map((r) => r.id),
148
159
  ...card.milestones.map((m) => m.id),
149
160
  ...card.intro_rates.map((i) => i.id),
161
+ ...card.credits.map((c) => c.id),
150
162
  ]);
151
163
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
152
164
  const backed = new Set(card.evidence.map((e) => e.rule));
@@ -249,6 +261,16 @@ export function catalogProblems(catalog) {
249
261
  problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
250
262
  }
251
263
  }
264
+ const all = new Map([...seed, ...overrides].map((m) => [m.id, m]));
265
+ for (const m of all.values()) {
266
+ if (m.brand_of === undefined)
267
+ continue;
268
+ const parent = all.get(m.brand_of);
269
+ if (!parent)
270
+ problems.push(`merchant ${m.id}: brand_of '${m.brand_of}' is not a merchant`);
271
+ else if (parent.brand_of !== undefined)
272
+ problems.push(`merchant ${m.id}: brand_of '${m.brand_of}' is itself a brand of '${parent.brand_of}'`);
273
+ }
252
274
  for (const m of catalog.memberships)
253
275
  for (const id of m.members_shop_at)
254
276
  if (!merchantIds.has(id))
@@ -86,7 +86,10 @@ export class JsonWalletStore {
86
86
  if (!wallet.cards.some((c) => c.id === purchase.cardId))
87
87
  return failed(`${purchase.cardId} isn't in the wallet.`);
88
88
  const booked = bookPurchase(this.catalog, wallet, purchase, merchant);
89
- if (booked.cap || booked.bonus || booked.milestones.length > 0)
89
+ if (booked.cap ||
90
+ booked.bonus ||
91
+ booked.milestones.length > 0 ||
92
+ booked.credit)
90
93
  await this.backend.write(JSON.stringify(booked.wallet));
91
94
  return { ok: true, ...booked };
92
95
  });
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { writeFileAtomic } from "./atomic-write.js";
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { isMap, isSeq, parseDocument, stringify } from 'yaml';
5
- import { applyBonusBooking, applyBooking, applyMilestoneBooking, } from "../engine/record.js";
5
+ import { applyBonusBooking, applyBooking, applyCreditBooking, applyMilestoneBooking, } from "../engine/record.js";
6
6
  import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
7
7
  const NEW_WALLET = `# Your earnrate wallet: the cards you hold, by id. Never card numbers.
8
8
  # Format: https://github.com/cwasley/earnrate#your-wallet
@@ -40,6 +40,18 @@ function fieldLines(field, f, indent) {
40
40
  return f.activated === undefined
41
41
  ? ''
42
42
  : `${indent}activated: ${flowList(f.activated)}\n`;
43
+ case 'enrolled':
44
+ return f.enrolled === undefined
45
+ ? ''
46
+ : `${indent}enrolled: ${flowList(f.enrolled)}\n`;
47
+ case 'credit_usage': {
48
+ if (f.credit_usage === undefined)
49
+ return '';
50
+ const entries = Object.entries(f.credit_usage)
51
+ .map(([key, u]) => `${indent} ${key}: { period: ${scalar(u.period)}, used: ${String(u.used)} }\n`)
52
+ .join('');
53
+ return entries === '' ? '' : `${indent}credit_usage:\n${entries}`;
54
+ }
43
55
  case 'cap_usage': {
44
56
  if (f.cap_usage === undefined)
45
57
  return '';
@@ -92,6 +104,8 @@ const FIELDS = [
92
104
  'activated',
93
105
  'cap_usage',
94
106
  'milestone_usage',
107
+ 'enrolled',
108
+ 'credit_usage',
95
109
  'bonus',
96
110
  ];
97
111
  function cardsSeq(text) {
@@ -281,9 +295,9 @@ function setValuationText(text, id, cents) {
281
295
  const indent = ' '.repeat(column(text, range(first?.key)[0]));
282
296
  return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
283
297
  }
284
- function flowUsage(period, spent) {
298
+ function flowUsage(period, spent, name = 'spent') {
285
299
  const key = /^\d+$/.test(period) ? `"${period}"` : period;
286
- return `{ period: ${key}, spent: ${String(spent)} }`;
300
+ return `{ period: ${key}, ${name}: ${String(spent)} }`;
287
301
  }
288
302
  // Sets one entry of a card's cap_usage or milestone_usage, adding the map
289
303
  // when the card has none.
@@ -324,9 +338,9 @@ function bonusSpentText(text, cardId, spent) {
324
338
  const at = afterLine(text, range(bonus)[1] - 1);
325
339
  return insertLines(text, at, `${indent}spent: ${value}\n`);
326
340
  }
327
- // Writes spending toward caps, welcome bonuses and spend milestones in one
328
- // checked write.
329
- export async function writeProgress(file, caps, bonuses, milestones = []) {
341
+ // Writes spending toward caps, welcome bonuses and spend milestones, and
342
+ // merchant credits used, in one checked write.
343
+ export async function writeProgress(file, caps, bonuses, milestones = [], credits = []) {
330
344
  const { text, wallet } = await readWalletText(file);
331
345
  if (text === null || wallet === null)
332
346
  throw new Error(`No wallet at ${file}.`);
@@ -344,10 +358,15 @@ export async function writeProgress(file, caps, bonuses, milestones = []) {
344
358
  after = bonusSpentText(after, b.cardId, b.after);
345
359
  expected = applyBonusBooking(expected, b);
346
360
  }
361
+ for (const c of credits) {
362
+ after = usageText(after, c.cardId, 'credit_usage', c.creditId, flowUsage(c.period, c.after, 'used'));
363
+ expected = applyCreditBooking(expected, c);
364
+ }
347
365
  const byHand = [
348
366
  ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
349
367
  ...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
350
368
  ...milestones.map((m) => `${m.cardId} milestone_usage ${m.milestoneId}: ${flowUsage(m.period, m.after)}`),
369
+ ...credits.map((c) => `${c.cardId} credit_usage ${c.creditId}: ${flowUsage(c.period, c.after, 'used')}`),
351
370
  ].join('; ');
352
371
  await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
353
372
  }
@@ -1,12 +1,12 @@
1
1
  import { categoryResolution, resolveMerchantById, } from "../merchants/resolve.js";
2
- import { exploreCards } from "./explore.js";
2
+ import { exploreCards, matchesFilters } from "./explore.js";
3
3
  import { bonusStatus } from "./bonus.js";
4
4
  import { EPSILON, isClose } from "./compare.js";
5
5
  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, feeText, listed, money, shortDate } from "./text.js";
9
+ import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
10
10
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
11
11
  // quick picks.
12
12
  export const EVERYDAY_PURCHASES = [
@@ -39,7 +39,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
39
39
  channel: null,
40
40
  date,
41
41
  country: 'US',
42
- }, merchant);
42
+ }, merchant, { credits: false });
43
43
  // The card that earns the most, even when a welcome bonus would take the
44
44
  // pick for one purchase: the sheet is for the whole quarter.
45
45
  const top = rec.decision ? rec.ranked[0] : undefined;
@@ -82,7 +82,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
82
82
  ...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
83
83
  ];
84
84
  return [
85
- `Working on ${name}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
85
+ `Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
86
86
  ? `It ${where.join(' and ')}, so those are the places to use it.`
87
87
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
88
88
  ];
@@ -101,6 +101,12 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
101
101
  : n.text)),
102
102
  ]),
103
103
  ];
104
+ const paying = paymentNote(wallet.cards.flatMap((held) => {
105
+ const card = catalog.cards.find((c) => c.id === held.id);
106
+ return card ? [{ name: held.checkout_label ?? card.name, card }] : [];
107
+ }));
108
+ if (paying !== null)
109
+ notes.push(paying);
104
110
  const [year, q] = (periodKey('quarter', date, null) ?? '').split('-');
105
111
  return {
106
112
  scope: 'wallet',
@@ -108,6 +114,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
108
114
  date,
109
115
  rows,
110
116
  notes,
117
+ assumptions: [CREDITS_LEFT_OUT],
111
118
  };
112
119
  }
113
120
  const ABROAD_LABEL = 'Abroad';
@@ -121,9 +128,10 @@ function abroadRow(catalog, wallet, date) {
121
128
  channel: null,
122
129
  date,
123
130
  country: ABROAD,
124
- }, merchant);
131
+ }, merchant, { credits: false });
125
132
  const top = rec.decision ? rec.ranked[0] : undefined;
126
- const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
133
+ const knownFee = (id) => catalog.cards.find((c) => c.id === id)?.foreign_transaction_fee ?? 0;
134
+ const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => knownFee(r.cardId) > 0);
127
135
  return {
128
136
  label: ABROAD_LABEL,
129
137
  cardId: top?.cardId ?? null,
@@ -192,6 +200,11 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
192
200
  quarter: `${q ?? ''} ${year ?? ''}`,
193
201
  date,
194
202
  rows,
195
- notes: [],
203
+ notes: [
204
+ paymentNote(catalog.cards
205
+ .filter((card) => matchesFilters(card, options.filters))
206
+ .map((card) => ({ name: card.name, card }))),
207
+ ].filter((n) => n !== null),
208
+ assumptions: [CREDITS_LEFT_OUT],
196
209
  };
197
210
  }
@@ -1,11 +1,12 @@
1
1
  import { currentPeriodEnd, daysBetween } from "./periods.js";
2
- import { CAP_PERIODS, choiceName, introEndsText, listed, money, shortDate, wholeDollars, } from "./text.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";
6
6
  // What the wallet's cards need this period, soonest first: bonuses to
7
7
  // activate, categories to pick, caps running low, welcome bonuses to finish,
8
- // and rotating categories the issuer hasn't posted yet.
8
+ // merchant credits to enroll in or use, and rotating categories the issuer
9
+ // hasn't posted yet.
9
10
  export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
10
11
  // A shorter card name than the issuer's full one, where the caller has it.
11
12
  shortName) {
@@ -23,7 +24,7 @@ shortName) {
23
24
  items.push({
24
25
  kind: 'activate',
25
26
  cardId: card.id,
26
- 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}.`,
27
28
  due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
28
29
  });
29
30
  }
@@ -40,7 +41,7 @@ shortName) {
40
41
  items.push({
41
42
  kind: 'pick',
42
43
  cardId: card.id,
43
- 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.`,
44
45
  due,
45
46
  });
46
47
  }
@@ -55,7 +56,7 @@ shortName) {
55
56
  items.push({
56
57
  kind: 'pick',
57
58
  cardId: card.id,
58
- 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)}.`,
59
60
  due: end,
60
61
  });
61
62
  }
@@ -65,7 +66,7 @@ shortName) {
65
66
  items.push({
66
67
  kind: 'pick',
67
68
  cardId: card.id,
68
- 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.`,
69
70
  due: null,
70
71
  });
71
72
  for (const cap of s.caps) {
@@ -77,7 +78,7 @@ shortName) {
77
78
  kind: 'cap',
78
79
  cardId: card.id,
79
80
  text: cap.remaining === 0
80
- ? `${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.`
81
82
  : `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
82
83
  due: resets,
83
84
  });
@@ -93,13 +94,36 @@ shortName) {
93
94
  due: m.until,
94
95
  });
95
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
+ });
106
+ for (const c of s.credits) {
107
+ if (!c.enrolled)
108
+ continue;
109
+ // One that needs no enrolling shows once the cardholder uses it, so a
110
+ // card's every credit isn't a to-do for someone who never shops there.
111
+ if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
112
+ items.push({
113
+ kind: 'credit',
114
+ cardId: card.id,
115
+ ref: c.id,
116
+ text: useCreditText(name, c, c.left),
117
+ due: c.until,
118
+ });
119
+ }
96
120
  const b = s.bonus;
97
121
  if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
98
122
  const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
99
123
  items.push({
100
124
  kind: 'bonus',
101
125
  cardId: card.id,
102
- 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.`,
103
127
  due: b.deadline,
104
128
  });
105
129
  }
@@ -118,7 +142,7 @@ shortName) {
118
142
  items.push({
119
143
  kind: 'rotation',
120
144
  cardId: card.id,
121
- 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.`,
122
146
  due: null,
123
147
  });
124
148
  }
@@ -127,7 +151,7 @@ shortName) {
127
151
  a.cardId.localeCompare(b.cardId));
128
152
  }
129
153
  // An all-day calendar event for each dated action (activations, picks,
130
- // welcome-bonus deadlines), with a reminder two days before. Built on the
154
+ // welcome-bonus deadlines, credits to use), with a reminder two days before. Built on the
131
155
  // device: nothing is sent anywhere.
132
156
  export function checklistCalendar(items, stamp) {
133
157
  const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
@@ -149,7 +173,7 @@ export function checklistCalendar(items, stamp) {
149
173
  const due = i.due ?? '';
150
174
  return [
151
175
  'BEGIN:VEVENT',
152
- `UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
176
+ `UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
153
177
  `DTSTAMP:${stamp}`,
154
178
  `DTSTART;VALUE=DATE:${day(due)}`,
155
179
  `DTEND;VALUE=DATE:${day(next(due))}`,
@@ -0,0 +1,65 @@
1
+ import { merchantFamily } from "../merchants/resolve.js";
2
+ import { categoryMatches } from "./evaluate.js";
3
+ import { periodEnd, periodKey } from "./periods.js";
4
+ import { creditName } from "./rule-text.js";
5
+ import { roundCents } from "./text.js";
6
+ // Only a store itself has a credit: a search for a kind of purchase
7
+ // ("clothing") never does.
8
+ // A credit naming a merchant also covers its brands (Hilton's covers Hampton).
9
+ export function creditCovers(catalog, credit, merchant) {
10
+ if (merchant.source === 'category' || merchant.source === 'none')
11
+ return false;
12
+ const named = credit.match.merchants;
13
+ if (named)
14
+ return merchantFamily(catalog, merchant.merchantId).some((id) => named.includes(id));
15
+ return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
16
+ }
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) {
20
+ const anniversary = held.anniversary ?? null;
21
+ const period = periodKey(credit.period, date, anniversary);
22
+ const usage = held.credit_usage?.[credit.id];
23
+ const used = period !== null && usage !== undefined && usage.period === period
24
+ ? usage.used
25
+ : 0;
26
+ return {
27
+ credit,
28
+ key: period,
29
+ name: creditName(catalog, credit),
30
+ amount: credit.amount,
31
+ period: credit.period,
32
+ enrolled: isEnrolled(credit, held),
33
+ used,
34
+ left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
35
+ until: period === null ? null : periodEnd(credit.period, period, anniversary),
36
+ };
37
+ }
38
+ export const creditStates = (catalog, card, held, date) => card.credits.map((c) => creditState(catalog, c, held, date));
39
+ export const counts = (s) => s.enrolled && s.left !== null && s.left > 0;
40
+ // The card's credit at this merchant, if any: one that counts first, then
41
+ // the one with the most left.
42
+ export function creditAt(catalog, card, held, merchant, date) {
43
+ const here = card.credits
44
+ .filter((c) => creditCovers(catalog, c, merchant))
45
+ .map((c) => creditState(catalog, c, held, date))
46
+ .sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
47
+ return here[0] ?? null;
48
+ }
49
+ // Dollars back on a purchase: null without an amount, 0 when the credit
50
+ // doesn't count (not enrolled, or used up).
51
+ export function creditDollars(s, amount) {
52
+ if (amount === null)
53
+ return null;
54
+ if (!counts(s) || amount <= 0)
55
+ return 0;
56
+ return roundCents(Math.min(amount, s.left ?? 0));
57
+ }
58
+ const PER_YEAR = {
59
+ month: 12,
60
+ quarter: 4,
61
+ half_year: 2,
62
+ calendar_year: 1,
63
+ cardmember_year: 1,
64
+ };
65
+ export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];