earnrate 0.2.12 → 0.2.13

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 (95) hide show
  1. package/README.md +20 -7
  2. package/data/CHANGELOG.md +181 -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 +27 -1
  9. package/data/cards/amex-delta-skymiles-platinum.yaml +33 -1
  10. package/data/cards/amex-delta-skymiles-reserve.yaml +33 -1
  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-platinum.yaml +110 -0
  17. package/data/cards/apple-card.yaml +76 -0
  18. package/data/cards/bilt-obsidian.yaml +12 -2
  19. package/data/cards/bilt-palladium.yaml +12 -2
  20. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  21. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  22. package/data/cards/capital-one-venture-x-business.yaml +28 -1
  23. package/data/cards/capital-one-venture-x.yaml +30 -2
  24. package/data/cards/chase-aeroplan.yaml +14 -0
  25. package/data/cards/chase-ihg-one-rewards-business.yaml +27 -1
  26. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +16 -0
  27. package/data/cards/chase-ihg-one-rewards-premier.yaml +15 -0
  28. package/data/cards/chase-ink-business-cash.yaml +13 -0
  29. package/data/cards/chase-ink-business-unlimited.yaml +13 -2
  30. package/data/cards/chase-sapphire-preferred.yaml +11 -0
  31. package/data/cards/chase-sapphire-reserve.yaml +44 -4
  32. package/data/cards/chase-united-business.yaml +32 -0
  33. package/data/cards/chase-united-club-business.yaml +40 -0
  34. package/data/cards/chase-united-club.yaml +40 -0
  35. package/data/cards/chase-united-explorer.yaml +32 -0
  36. package/data/cards/chase-united-quest.yaml +40 -0
  37. package/data/cards/citi-aadvantage-executive.yaml +26 -1
  38. package/data/cards/citi-strata-elite.yaml +20 -1
  39. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  40. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  41. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  42. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  43. package/data/cards/paypal-cashback-mastercard.yaml +13 -2
  44. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  45. package/data/cards/usbank-altitude-connect.yaml +8 -2
  46. package/data/cards/usbank-altitude-go.yaml +8 -2
  47. package/data/cards/usbank-smartly.yaml +107 -0
  48. package/data/cards/venmo-credit-card.yaml +151 -0
  49. package/data/cards/verizon-visa-card.yaml +122 -0
  50. package/data/currencies.yaml +14 -0
  51. package/data/issuers.yaml +22 -0
  52. package/data/memberships.yaml +35 -0
  53. package/data/merchants/overrides.yaml +448 -0
  54. package/data/merchants/seed.yaml +1082 -65
  55. package/dist/cli/args.js +18 -0
  56. package/dist/cli/explore.js +10 -1
  57. package/dist/cli/run.js +4 -1
  58. package/dist/cli/wallet.js +30 -3
  59. package/dist/data/file-store.js +5 -2
  60. package/dist/data/integrity.js +22 -0
  61. package/dist/data/store.js +4 -1
  62. package/dist/data/wallet-edit.js +25 -6
  63. package/dist/engine/cheat-sheet.js +17 -5
  64. package/dist/engine/checklist.js +25 -4
  65. package/dist/engine/credits.js +65 -0
  66. package/dist/engine/evaluate.js +38 -11
  67. package/dist/engine/explore.js +70 -7
  68. package/dist/engine/offers.js +2 -1
  69. package/dist/engine/periods.js +10 -3
  70. package/dist/engine/recommend.js +72 -20
  71. package/dist/engine/record.js +41 -1
  72. package/dist/engine/rotation.js +3 -0
  73. package/dist/engine/rule-text.js +14 -3
  74. package/dist/engine/setup.js +15 -4
  75. package/dist/engine/simulate.js +10 -1
  76. package/dist/engine/text.js +84 -0
  77. package/dist/engine/wallet-change.js +37 -1
  78. package/dist/engine/wallet-summary.js +32 -4
  79. package/dist/format/card-text.js +4 -3
  80. package/dist/format/cheat-sheet.js +2 -0
  81. package/dist/format/explore.js +20 -4
  82. package/dist/format/recommendation.js +6 -2
  83. package/dist/format/wallet.js +6 -2
  84. package/dist/import/analyze.js +7 -2
  85. package/dist/mcp/inputs.js +10 -0
  86. package/dist/mcp/prompts.js +9 -1
  87. package/dist/mcp/schemas.js +70 -1
  88. package/dist/mcp/tools/answers.js +9 -4
  89. package/dist/mcp/tools/wallet.js +15 -7
  90. package/dist/merchants/resolve.js +8 -0
  91. package/dist/schema/card.js +37 -3
  92. package/dist/schema/common.js +1 -1
  93. package/dist/schema/merchant.js +3 -0
  94. package/dist/schema/wallet.js +8 -0
  95. 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
  }
@@ -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, } 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;
@@ -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,7 +128,7 @@ 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
133
  const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
127
134
  return {
@@ -192,6 +199,11 @@ export function catalogCheatSheet(catalog, date, options, items = EVERYDAY_PURCH
192
199
  quarter: `${q ?? ''} ${year ?? ''}`,
193
200
  date,
194
201
  rows,
195
- notes: [],
202
+ notes: [
203
+ paymentNote(catalog.cards
204
+ .filter((card) => matchesFilters(card, options.filters))
205
+ .map((card) => ({ name: card.name, card }))),
206
+ ].filter((n) => n !== null),
207
+ assumptions: [CREDITS_LEFT_OUT],
196
208
  };
197
209
  }
@@ -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, } 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) {
@@ -93,6 +94,26 @@ shortName) {
93
94
  due: m.until,
94
95
  });
95
96
  }
97
+ for (const c of s.credits) {
98
+ 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
+ });
106
+ // One that needs no enrolling shows once the cardholder uses it, so a
107
+ // 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))
109
+ items.push({
110
+ kind: 'credit',
111
+ cardId: card.id,
112
+ ref: c.id,
113
+ text: useCreditText(name, c, c.left),
114
+ due: c.until,
115
+ });
116
+ }
96
117
  const b = s.bonus;
97
118
  if (b?.state === 'open' && b.remaining !== null && b.deadline !== null) {
98
119
  const weeks = Math.max(1, (b.daysLeft ?? 7) / 7);
@@ -127,7 +148,7 @@ shortName) {
127
148
  a.cardId.localeCompare(b.cardId));
128
149
  }
129
150
  // An all-day calendar event for each dated action (activations, picks,
130
- // welcome-bonus deadlines), with a reminder two days before. Built on the
151
+ // welcome-bonus deadlines, credits to use), with a reminder two days before. Built on the
131
152
  // device: nothing is sent anywhere.
132
153
  export function checklistCalendar(items, stamp) {
133
154
  const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
@@ -149,7 +170,7 @@ export function checklistCalendar(items, stamp) {
149
170
  const due = i.due ?? '';
150
171
  return [
151
172
  'BEGIN:VEVENT',
152
- `UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
173
+ `UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
153
174
  `DTSTAMP:${stamp}`,
154
175
  `DTSTART;VALUE=DATE:${day(due)}`,
155
176
  `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];
@@ -1,17 +1,23 @@
1
+ import { merchantFamily } from "../merchants/resolve.js";
2
+ import { EPSILON } from "./compare.js";
1
3
  import { introCovers, introWindows, withIntro, } from "./intro.js";
2
4
  import { inEffect, periodKey } from "./periods.js";
5
+ // PayPal checkout happens online, so paying through it says the channel.
6
+ export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
3
7
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
8
+ const payment = purchase.payment ?? null;
4
9
  return {
5
10
  category,
6
- merchantId: merchant.merchantId,
11
+ merchantIds: merchantFamily(catalog, merchant.merchantId),
7
12
  mcc: category === merchant.category && merchant.likelyMcc !== null
8
13
  ? merchant.likelyMcc
9
14
  : singleMcc(catalog, category),
10
- channel: merchant.channel,
15
+ channel: paymentChannel(merchant.channel, payment),
11
16
  country: purchase.country,
12
17
  date: purchase.date,
13
18
  amount: purchase.amount,
14
19
  memberships: new Set(wallet.memberships),
20
+ payment,
15
21
  };
16
22
  }
17
23
  // A category with one typical MCC (travel agencies are 4722) implies it, the
@@ -79,18 +85,16 @@ export function isActivated(card, held, ruleId) {
79
85
  function applicability(card, rule, ctx, held, picked) {
80
86
  const { match, exclude } = rule;
81
87
  const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
88
+ const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
82
89
  if (match.categories?.length && !inCategory(match.categories))
83
90
  return 'no';
84
- if (match.merchants?.length &&
85
- !(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
91
+ if (match.merchants?.length && !named(match.merchants))
86
92
  return 'no';
87
- }
88
93
  if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
89
94
  return 'no';
90
95
  if (match.choice !== undefined) {
91
96
  const covered = picked.get(match.choice);
92
- const merchantPicked = ctx.merchantId !== null &&
93
- (covered?.merchants.includes(ctx.merchantId) ?? false);
97
+ const merchantPicked = named(covered?.merchants);
94
98
  const onlinePicked = ctx.channel === 'online' &&
95
99
  (covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
96
100
  if (!merchantPicked &&
@@ -100,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
100
104
  }
101
105
  if (exclude?.categories && inCategory(exclude.categories))
102
106
  return 'no';
103
- if (exclude?.merchants &&
104
- ctx.merchantId !== null &&
105
- exclude.merchants.includes(ctx.merchantId)) {
107
+ if (named(exclude?.merchants))
106
108
  return 'no';
107
- }
108
109
  if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
109
110
  return 'no';
111
+ if (exclude?.payment &&
112
+ ctx.payment !== null &&
113
+ exclude.payment.includes(ctx.payment))
114
+ return 'no';
110
115
  if (rule.channel !== 'any' && rule.channel !== ctx.channel)
111
116
  return 'no';
112
117
  if (rule.geography === 'us_only' && ctx.country !== 'US')
@@ -118,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
118
123
  const membership = rule.requires?.membership;
119
124
  if (membership !== undefined && !ctx.memberships.has(membership))
120
125
  return 'no';
126
+ const payment = rule.requires?.payment;
127
+ if (payment !== undefined && ctx.payment !== payment)
128
+ return 'no';
121
129
  if (rule.requires?.activation && !isActivated(card, held, rule.id))
122
130
  return 'needs-activation';
123
131
  return 'applies';
@@ -222,3 +230,22 @@ export function evaluateCard(card, held, ctx) {
222
230
  staleChoice,
223
231
  };
224
232
  }
233
+ // The way of paying (one a rule on the card requires) that would earn the
234
+ // most on this purchase, when that beats `rate`. Only while the purchase
235
+ // doesn't say how it's paid.
236
+ export function paymentUpgrade(card, held, ctx, rate) {
237
+ if (ctx.payment !== null)
238
+ return null;
239
+ let best = null;
240
+ const methods = new Set(card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]));
241
+ for (const payment of methods) {
242
+ const paid = evaluateCard(card, held, {
243
+ ...ctx,
244
+ payment,
245
+ channel: paymentChannel(ctx.channel, payment),
246
+ }).best;
247
+ if (paid.rate > (best?.rate ?? rate) + EPSILON)
248
+ best = { payment, rate: paid.rate, nominalRate: paid.nominalRate };
249
+ }
250
+ return best;
251
+ }