earnrate 0.2.11 → 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 (158) hide show
  1. package/README.md +37 -12
  2. package/data/CHANGELOG.md +291 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-business-plus.yaml +1 -0
  5. package/data/cards/amex-blue-cash-everyday.yaml +29 -2
  6. package/data/cards/amex-blue-cash-preferred.yaml +26 -0
  7. package/data/cards/amex-business-gold.yaml +169 -0
  8. package/data/cards/amex-business-platinum.yaml +184 -0
  9. package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
  10. package/data/cards/amex-delta-skymiles-gold.yaml +29 -1
  11. package/data/cards/amex-delta-skymiles-platinum.yaml +35 -1
  12. package/data/cards/amex-delta-skymiles-reserve.yaml +35 -1
  13. package/data/cards/amex-gold.yaml +53 -0
  14. package/data/cards/amex-hilton-honors-aspire.yaml +25 -1
  15. package/data/cards/amex-hilton-honors-business.yaml +106 -0
  16. package/data/cards/amex-hilton-honors-surpass.yaml +16 -1
  17. package/data/cards/amex-hilton-honors.yaml +2 -0
  18. package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
  19. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +17 -1
  20. package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
  21. package/data/cards/amex-platinum.yaml +111 -0
  22. package/data/cards/apple-card.yaml +76 -0
  23. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
  24. package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
  25. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
  26. package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
  27. package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
  28. package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
  29. package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
  30. package/data/cards/barclays-jetblue-plus.yaml +86 -0
  31. package/data/cards/bilt-blue.yaml +60 -0
  32. package/data/cards/bilt-obsidian.yaml +13 -2
  33. package/data/cards/bilt-palladium.yaml +86 -0
  34. package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
  35. package/data/cards/capital-one-kohls-card.yaml +1 -0
  36. package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
  37. package/data/cards/capital-one-quicksilver.yaml +2 -0
  38. package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
  39. package/data/cards/capital-one-savor.yaml +2 -0
  40. package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
  41. package/data/cards/capital-one-venture-x-business.yaml +124 -0
  42. package/data/cards/capital-one-venture-x.yaml +32 -2
  43. package/data/cards/capital-one-venture.yaml +2 -0
  44. package/data/cards/chase-aeroplan.yaml +16 -0
  45. package/data/cards/chase-freedom-flex.yaml +1 -0
  46. package/data/cards/chase-freedom-rise.yaml +29 -0
  47. package/data/cards/chase-freedom-unlimited.yaml +1 -0
  48. package/data/cards/chase-ihg-one-rewards-business.yaml +28 -1
  49. package/data/cards/chase-ihg-one-rewards-premier-select.yaml +18 -0
  50. package/data/cards/chase-ihg-one-rewards-premier.yaml +16 -0
  51. package/data/cards/chase-ihg-one-rewards.yaml +1 -0
  52. package/data/cards/chase-ink-business-cash.yaml +14 -0
  53. package/data/cards/chase-ink-business-preferred.yaml +1 -0
  54. package/data/cards/chase-ink-business-unlimited.yaml +80 -0
  55. package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
  56. package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
  57. package/data/cards/chase-prime-visa.yaml +1 -0
  58. package/data/cards/chase-sapphire-preferred.yaml +12 -0
  59. package/data/cards/chase-sapphire-reserve.yaml +45 -4
  60. package/data/cards/chase-southwest-performance-business.yaml +2 -0
  61. package/data/cards/chase-southwest-plus.yaml +2 -0
  62. package/data/cards/chase-southwest-premier-business.yaml +2 -0
  63. package/data/cards/chase-southwest-premier.yaml +2 -0
  64. package/data/cards/chase-southwest-priority.yaml +2 -0
  65. package/data/cards/chase-united-business.yaml +33 -0
  66. package/data/cards/chase-united-club-business.yaml +41 -0
  67. package/data/cards/chase-united-club.yaml +41 -0
  68. package/data/cards/chase-united-explorer.yaml +33 -0
  69. package/data/cards/chase-united-gateway.yaml +1 -0
  70. package/data/cards/chase-united-quest.yaml +41 -0
  71. package/data/cards/citi-aadvantage-business.yaml +2 -0
  72. package/data/cards/citi-aadvantage-executive.yaml +28 -1
  73. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  74. package/data/cards/citi-aadvantage-mileup.yaml +2 -0
  75. package/data/cards/citi-aadvantage-platinum-select.yaml +2 -0
  76. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  77. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  78. package/data/cards/citi-custom-cash.yaml +1 -0
  79. package/data/cards/citi-double-cash.yaml +1 -0
  80. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  81. package/data/cards/citi-strata-elite.yaml +21 -1
  82. package/data/cards/citi-strata-premier.yaml +1 -0
  83. package/data/cards/citi-strata.yaml +164 -0
  84. package/data/cards/discover-it-cash-back.yaml +22 -3
  85. package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
  86. package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
  87. package/data/cards/navy-federal-cash-rewards.yaml +60 -0
  88. package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
  89. package/data/cards/paypal-cashback-mastercard.yaml +52 -0
  90. package/data/cards/penfed-power-cash-rewards.yaml +47 -0
  91. package/data/cards/sams-club-mastercard.yaml +68 -0
  92. package/data/cards/sofi-unlimited-2.yaml +1 -0
  93. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  94. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  95. package/data/cards/td-bank-target-circle-card.yaml +1 -0
  96. package/data/cards/usbank-altitude-connect.yaml +156 -0
  97. package/data/cards/usbank-altitude-go.yaml +124 -0
  98. package/data/cards/usbank-cash-plus.yaml +1 -0
  99. package/data/cards/usbank-smartly.yaml +107 -0
  100. package/data/cards/venmo-credit-card.yaml +151 -0
  101. package/data/cards/verizon-visa-card.yaml +122 -0
  102. package/data/cards/wells-fargo-active-cash.yaml +1 -0
  103. package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
  104. package/data/cards/wells-fargo-autograph.yaml +1 -0
  105. package/data/categories.yaml +3 -1
  106. package/data/currencies.yaml +48 -0
  107. package/data/issuers.yaml +44 -1
  108. package/data/memberships.yaml +45 -0
  109. package/data/merchants/overrides.yaml +947 -1
  110. package/data/merchants/seed.yaml +1916 -62
  111. package/dist/cli/args.js +18 -0
  112. package/dist/cli/explore.js +10 -1
  113. package/dist/cli/import.js +2 -2
  114. package/dist/cli/run.js +4 -1
  115. package/dist/cli/wallet.js +37 -3
  116. package/dist/data/file-store.js +5 -2
  117. package/dist/data/integrity.js +59 -0
  118. package/dist/data/store.js +4 -1
  119. package/dist/data/wallet-edit.js +28 -7
  120. package/dist/engine/cheat-sheet.js +17 -5
  121. package/dist/engine/checklist.js +38 -6
  122. package/dist/engine/credits.js +65 -0
  123. package/dist/engine/evaluate.js +57 -18
  124. package/dist/engine/explore.js +70 -7
  125. package/dist/engine/intro.js +36 -0
  126. package/dist/engine/offers.js +2 -1
  127. package/dist/engine/periods.js +10 -3
  128. package/dist/engine/recommend.js +101 -20
  129. package/dist/engine/record.js +43 -1
  130. package/dist/engine/rotation.js +3 -0
  131. package/dist/engine/rule-text.js +28 -3
  132. package/dist/engine/setup.js +20 -3
  133. package/dist/engine/simulate.js +10 -1
  134. package/dist/engine/text.js +107 -0
  135. package/dist/engine/wallet-change.js +57 -4
  136. package/dist/engine/wallet-summary.js +54 -6
  137. package/dist/format/card-art.js +21 -0
  138. package/dist/format/card-text.js +4 -3
  139. package/dist/format/cards.js +1 -0
  140. package/dist/format/cheat-sheet.js +2 -0
  141. package/dist/format/explore.js +20 -4
  142. package/dist/format/recommendation.js +6 -2
  143. package/dist/format/wallet-yaml.js +1 -0
  144. package/dist/format/wallet.js +15 -5
  145. package/dist/import/analyze.js +8 -2
  146. package/dist/mcp/inputs.js +13 -0
  147. package/dist/mcp/prompts.js +9 -1
  148. package/dist/mcp/schemas.js +87 -1
  149. package/dist/mcp/tools/account.js +3 -1
  150. package/dist/mcp/tools/answers.js +9 -4
  151. package/dist/mcp/tools/wallet.js +20 -8
  152. package/dist/merchants/resolve.js +8 -0
  153. package/dist/schema/card.js +60 -3
  154. package/dist/schema/common.js +1 -1
  155. package/dist/schema/issuer.js +4 -0
  156. package/dist/schema/merchant.js +3 -0
  157. package/dist/schema/wallet.js +11 -0
  158. package/package.json +12 -3
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`
@@ -13,7 +13,7 @@ import { cardValuation } from "../engine/valuation.js";
13
13
  import { analyzeStatement, } from "../import/analyze.js";
14
14
  import { parseStatement } from "../import/statement.js";
15
15
  import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
16
- import { currencyName, dollarsAndCents } from "../engine/text.js";
16
+ import { currencyName, dollarsAndCents, periodPhrase } from "../engine/text.js";
17
17
  const IMPORT_HELP = `Usage: ${CLI_NAME} import <statement.csv> --card <card-id> [--apply]
18
18
  ${CLI_NAME} import <statement.csv>:<card-id> [<statement.csv>:<card-id> ...]
19
19
 
@@ -133,7 +133,7 @@ function formatAnalysis(a, labelOf, valuation, categoryName) {
133
133
  out.push(`Welcome bonus on ${labelOf(b.cardId)}: ${dollarsAndCents(b.before)} → ${dollarsAndCents(b.after)} of ${dollarsAndCents(b.spend)}${b.deadline ? ` by ${b.deadline}` : ''}.`);
134
134
  }
135
135
  for (const m of a.milestones) {
136
- out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} for ${m.period}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
136
+ out.push(`Spend bonus on ${labelOf(m.cardId)}: ${dollarsAndCents(m.before)} → ${dollarsAndCents(m.after)} of ${dollarsAndCents(m.spend)} ${periodPhrase(m.periodKind, m.period)}${m.before < m.spend && m.after >= m.spend ? ', reached' : ''}.`);
137
137
  }
138
138
  return `${out.join('\n')}\n`;
139
139
  }
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,
@@ -14,8 +14,17 @@ const CARD_OPTIONS = `Options:
14
14
  --pick five-percent=fast-food,movie-theaters.
15
15
  Repeat for each choice.
16
16
  --anniversary <MM-DD> Account anniversary, for caps that reset on it.
17
+ --opened <date> Optional: the day the account opened (YYYY-MM-DD),
18
+ for cards with a first-year rate. Without it the
19
+ card is ranked at its standard rates.
17
20
  --activated <rules> Rules you've activated with the issuer, comma
18
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.
19
28
  --bonus-opened <date> Track a welcome bonus: the day the account opened
20
29
  (YYYY-MM-DD). Spending needed and the deadline
21
30
  default to the card's published offer.
@@ -44,8 +53,8 @@ ${CARD_OPTIONS}
44
53
  update: `Usage: ${CLI_NAME} update <card-id> [options]
45
54
 
46
55
  Changes a card in your wallet: picks for this period, label, anniversary,
47
- activations or welcome bonus. Picks for the current period are merged with
48
- ones already set.
56
+ opening date, activations, merchant credits or welcome bonus. Picks for the
57
+ current period are merged with ones already set.
49
58
 
50
59
  ${CARD_OPTIONS}
51
60
  `,
@@ -130,7 +139,10 @@ export async function runWalletCommand(command, argv, io) {
130
139
  label: { type: 'string' },
131
140
  pick: { type: 'string', multiple: true },
132
141
  anniversary: { type: 'string' },
142
+ opened: { type: 'string' },
133
143
  activated: { type: 'string' },
144
+ enrolled: { type: 'string' },
145
+ 'credit-used': { type: 'string', multiple: true },
134
146
  'bonus-opened': { type: 'string' },
135
147
  'bonus-spent': { type: 'string' },
136
148
  'bonus-spend': { type: 'string' },
@@ -158,7 +170,10 @@ export async function runWalletCommand(command, argv, io) {
158
170
  const cardOptionsGiven = values.label !== undefined ||
159
171
  values.pick !== undefined ||
160
172
  values.anniversary !== undefined ||
161
- values.activated !== undefined;
173
+ values.opened !== undefined ||
174
+ values.activated !== undefined ||
175
+ values.enrolled !== undefined ||
176
+ values['credit-used'] !== undefined;
162
177
  if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
163
178
  return usage(`${CLI_NAME} ${command} doesn't take card options.`);
164
179
  }
@@ -236,6 +251,8 @@ export async function runWalletCommand(command, argv, io) {
236
251
  input.checkoutLabel = values.label;
237
252
  if (values.anniversary !== undefined)
238
253
  input.anniversary = values.anniversary;
254
+ if (values.opened !== undefined)
255
+ input.opened = values.opened;
239
256
  if (values.activated !== undefined) {
240
257
  input.activated = values.activated
241
258
  .split(',')
@@ -254,6 +271,23 @@ export async function runWalletCommand(command, argv, io) {
254
271
  const n = Number(text.replace(/[$,]/g, ''));
255
272
  return Number.isFinite(n) ? n : `--${flag} takes dollars, like 1200.`;
256
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
+ }
257
291
  if (values['no-bonus']) {
258
292
  input.bonus = null;
259
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));
@@ -1,3 +1,4 @@
1
+ import { resolveMerchant } from "../merchants/resolve.js";
1
2
  import { WALLET_VERSION } from "../schema/wallet.js";
2
3
  function duplicates(ids) {
3
4
  const seen = new Set();
@@ -38,6 +39,31 @@ function merchantProblems(file, merchants, categories) {
38
39
  }
39
40
  return problems;
40
41
  }
42
+ // Overrides are searched before seed, and a known name followed by more words
43
+ // still matches it, so a name can be taken by another merchant without ever
44
+ // being listed twice.
45
+ function lookupProblems(catalog) {
46
+ const problems = [];
47
+ const { seed, overrides } = catalog.merchants;
48
+ for (const m of [...overrides, ...seed]) {
49
+ for (const key of [m.name, ...m.aliases, ...m.domains]) {
50
+ const found = resolveMerchant(catalog, key, null).merchantId;
51
+ if (found !== m.id)
52
+ problems.push(`merchant ${m.id}: '${key}' resolves to ${found ?? 'no merchant'}`);
53
+ }
54
+ }
55
+ for (const c of catalog.categories)
56
+ for (const word of new Set([
57
+ c.name,
58
+ ...c.aliases,
59
+ c.id.replace(/[._]/g, ' '),
60
+ ])) {
61
+ const found = resolveMerchant(catalog, word, null).merchantId;
62
+ if (found !== null)
63
+ problems.push(`category ${c.id}: '${word}' resolves to ${found}`);
64
+ }
65
+ return problems;
66
+ }
41
67
  function cardProblems(card, catalog, merchantIds) {
42
68
  const where = `card ${card.id}`;
43
69
  const problems = [];
@@ -87,6 +113,8 @@ function cardProblems(card, catalog, merchantIds) {
87
113
  ['cap', card.caps.map((c) => c.id)],
88
114
  ['choice', card.choices.map((c) => c.id)],
89
115
  ['milestone', card.milestones.map((m) => m.id)],
116
+ ['intro rate', card.intro_rates.map((i) => i.id)],
117
+ ['credit', card.credits.map((c) => c.id)],
90
118
  ]) {
91
119
  for (const id of duplicates(ids))
92
120
  problems.push(`${where}: duplicate ${kind} id '${id}'`);
@@ -95,6 +123,24 @@ function cardProblems(card, catalog, merchantIds) {
95
123
  for (const m of card.milestones)
96
124
  if (card.rules.some((r) => r.id === m.id))
97
125
  problems.push(`${where}: milestone '${m.id}' has a rule's id`);
126
+ for (const intro of card.intro_rates) {
127
+ if (card.rules.some((r) => r.id === intro.id) ||
128
+ card.milestones.some((m) => m.id === intro.id))
129
+ problems.push(`${where}: intro rate '${intro.id}' has a rule's id`);
130
+ for (const id of intro.rules ?? [])
131
+ if (!card.rules.some((r) => r.id === id))
132
+ problems.push(`${where} intro rate ${intro.id}: unknown rule '${id}'`);
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
+ }
98
144
  if (card.foreign_transaction_fee === null &&
99
145
  !card.todo.some((t) => /foreign transaction fee/i.test(t)))
100
146
  problems.push(`${where}: foreign_transaction_fee is null without a todo saying why`);
@@ -111,6 +157,8 @@ function cardProblems(card, catalog, merchantIds) {
111
157
  ...(card.requires_membership ? ['requires_membership'] : []),
112
158
  ...card.rules.map((r) => r.id),
113
159
  ...card.milestones.map((m) => m.id),
160
+ ...card.intro_rates.map((i) => i.id),
161
+ ...card.credits.map((c) => c.id),
114
162
  ]);
115
163
  const sourcePages = new Set(card.source_urls.map((u) => u.split('#')[0]));
116
164
  const backed = new Set(card.evidence.map((e) => e.rule));
@@ -205,6 +253,7 @@ export function catalogProblems(catalog) {
205
253
  const { seed, overrides } = catalog.merchants;
206
254
  problems.push(...merchantProblems('seed', seed, categories));
207
255
  problems.push(...merchantProblems('overrides', overrides, categories));
256
+ problems.push(...lookupProblems(catalog));
208
257
  const merchantIds = new Set([...seed, ...overrides].map((m) => m.id));
209
258
  for (const m of [...seed, ...overrides]) {
210
259
  for (const via of m.booked_via) {
@@ -212,6 +261,16 @@ export function catalogProblems(catalog) {
212
261
  problems.push(`merchant ${m.id}: booked_via '${via}' is not a merchant`);
213
262
  }
214
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
+ }
215
274
  for (const m of catalog.memberships)
216
275
  for (const id of m.members_shop_at)
217
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
@@ -31,7 +31,8 @@ const flowList = (ids) => `[${ids.join(', ')}]`;
31
31
  function fieldLines(field, f, indent) {
32
32
  switch (field) {
33
33
  case 'checkout_label':
34
- case 'anniversary': {
34
+ case 'anniversary':
35
+ case 'opened': {
35
36
  const value = f[field];
36
37
  return value == null ? '' : `${indent}${field}: ${scalar(value)}\n`;
37
38
  }
@@ -39,6 +40,18 @@ function fieldLines(field, f, indent) {
39
40
  return f.activated === undefined
40
41
  ? ''
41
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
+ }
42
55
  case 'cap_usage': {
43
56
  if (f.cap_usage === undefined)
44
57
  return '';
@@ -86,10 +99,13 @@ function fieldLines(field, f, indent) {
86
99
  const FIELDS = [
87
100
  'checkout_label',
88
101
  'anniversary',
102
+ 'opened',
89
103
  'chosen',
90
104
  'activated',
91
105
  'cap_usage',
92
106
  'milestone_usage',
107
+ 'enrolled',
108
+ 'credit_usage',
93
109
  'bonus',
94
110
  ];
95
111
  function cardsSeq(text) {
@@ -279,9 +295,9 @@ function setValuationText(text, id, cents) {
279
295
  const indent = ' '.repeat(column(text, range(first?.key)[0]));
280
296
  return insertLines(text, afterLine(text, range(last?.value ?? last?.key)[1] - 1), `${indent}${line}\n`);
281
297
  }
282
- function flowUsage(period, spent) {
298
+ function flowUsage(period, spent, name = 'spent') {
283
299
  const key = /^\d+$/.test(period) ? `"${period}"` : period;
284
- return `{ period: ${key}, spent: ${String(spent)} }`;
300
+ return `{ period: ${key}, ${name}: ${String(spent)} }`;
285
301
  }
286
302
  // Sets one entry of a card's cap_usage or milestone_usage, adding the map
287
303
  // when the card has none.
@@ -322,9 +338,9 @@ function bonusSpentText(text, cardId, spent) {
322
338
  const at = afterLine(text, range(bonus)[1] - 1);
323
339
  return insertLines(text, at, `${indent}spent: ${value}\n`);
324
340
  }
325
- // Writes spending toward caps, welcome bonuses and spend milestones in one
326
- // checked write.
327
- 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 = []) {
328
344
  const { text, wallet } = await readWalletText(file);
329
345
  if (text === null || wallet === null)
330
346
  throw new Error(`No wallet at ${file}.`);
@@ -342,10 +358,15 @@ export async function writeProgress(file, caps, bonuses, milestones = []) {
342
358
  after = bonusSpentText(after, b.cardId, b.after);
343
359
  expected = applyBonusBooking(expected, b);
344
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
+ }
345
365
  const byHand = [
346
366
  ...caps.map((b) => `${b.cardId} cap_usage ${b.capKey}: ${flowUsage(b.period, b.after)}`),
347
367
  ...bonuses.map((b) => `${b.cardId} bonus spent: ${String(b.after)}`),
348
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')}`),
349
370
  ].join('; ');
350
371
  await commitWalletText(file, after, expected, `add it by hand: ${byHand}`);
351
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, 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) {
@@ -18,12 +19,12 @@ shortName) {
18
19
  const name = s.checkoutLabel ?? shortName?.(card.id) ?? s.cardName;
19
20
  for (const a of activationsDue(catalog, card, held, today)) {
20
21
  const starts = a.from !== null && a.from > today
21
- ? `, starting ${shortDate(a.from)}`
22
+ ? ` (it starts ${shortDate(a.from)})`
22
23
  : '';
23
24
  items.push({
24
25
  kind: 'activate',
25
26
  cardId: card.id,
26
- text: `Activate ${name}'s bonus on ${a.what}${starts}${a.activateBy === null ? '' : `, by ${shortDate(a.activateBy)}`}.`,
27
+ text: `Activate ${name}'s 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
  }
@@ -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);
@@ -103,6 +124,17 @@ shortName) {
103
124
  due: b.deadline,
104
125
  });
105
126
  }
127
+ const quarterEnd = currentPeriodEnd('quarter', today, null);
128
+ if (s.intro?.state === 'active' &&
129
+ s.intro.until !== null &&
130
+ quarterEnd !== null &&
131
+ s.intro.until <= quarterEnd)
132
+ items.push({
133
+ kind: 'intro',
134
+ cardId: card.id,
135
+ text: introEndsText(name, s.intro.days, s.intro.until),
136
+ due: s.intro.until,
137
+ });
106
138
  if (s.rotationGap !== null) {
107
139
  items.push({
108
140
  kind: 'rotation',
@@ -116,7 +148,7 @@ shortName) {
116
148
  a.cardId.localeCompare(b.cardId));
117
149
  }
118
150
  // An all-day calendar event for each dated action (activations, picks,
119
- // 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
120
152
  // device: nothing is sent anywhere.
121
153
  export function checklistCalendar(items, stamp) {
122
154
  const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
@@ -138,7 +170,7 @@ export function checklistCalendar(items, stamp) {
138
170
  const due = i.due ?? '';
139
171
  return [
140
172
  'BEGIN:VEVENT',
141
- `UID:${i.kind}-${i.cardId}-${due}@earnrate.dev`,
173
+ `UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
142
174
  `DTSTAMP:${stamp}`,
143
175
  `DTSTART;VALUE=DATE:${day(due)}`,
144
176
  `DTEND;VALUE=DATE:${day(next(due))}`,