earnrate 0.2.14 → 0.2.16

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 (45) hide show
  1. package/README.md +10 -10
  2. package/data/CHANGELOG.md +12 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
  5. package/data/cards/discover-it-cash-back.yaml +1 -1
  6. package/data/cards/venmo-credit-card.yaml +1 -1
  7. package/data/categories.yaml +10 -0
  8. package/dist/cli/args.js +30 -1
  9. package/dist/cli/import.js +14 -11
  10. package/dist/cli/run.js +10 -1
  11. package/dist/cli/todo.js +3 -2
  12. package/dist/cli/wallet.js +49 -16
  13. package/dist/engine/cheat-sheet.js +13 -10
  14. package/dist/engine/checklist.js +5 -10
  15. package/dist/engine/credits.js +2 -2
  16. package/dist/engine/evaluate.js +1 -1
  17. package/dist/engine/explore.js +7 -3
  18. package/dist/engine/intro.js +1 -5
  19. package/dist/engine/memberships.js +3 -7
  20. package/dist/engine/offers.js +3 -5
  21. package/dist/engine/periods.js +5 -0
  22. package/dist/engine/profiles.js +2 -1
  23. package/dist/engine/recommend.js +19 -17
  24. package/dist/engine/record.js +1 -1
  25. package/dist/engine/rule-text.js +71 -23
  26. package/dist/engine/setup.js +4 -0
  27. package/dist/engine/text.js +23 -6
  28. package/dist/engine/wallet-change.js +5 -5
  29. package/dist/engine/wallet-summary.js +32 -23
  30. package/dist/format/card-text.js +6 -5
  31. package/dist/format/cards.js +2 -2
  32. package/dist/format/explore.js +2 -2
  33. package/dist/format/wallet-yaml.js +2 -2
  34. package/dist/format/wallet.js +5 -5
  35. package/dist/import/analyze.js +13 -1
  36. package/dist/mcp/context.js +2 -3
  37. package/dist/mcp/inputs.js +11 -3
  38. package/dist/mcp/schemas.js +2 -0
  39. package/dist/mcp/server.js +0 -2
  40. package/dist/mcp/tools/account.js +50 -42
  41. package/dist/mcp/tools/answers.js +8 -4
  42. package/dist/mcp/tools/wallet.js +205 -148
  43. package/dist/mcp/wallet-api.js +78 -0
  44. package/dist/schema/card.js +6 -10
  45. package/package.json +2 -1
package/README.md CHANGED
@@ -58,11 +58,11 @@ Assumes Whole Foods Market codes as supermarkets (MCC 5411).
58
58
  Values points at: Amex Membership Rewards (MR) at 1¢, SoFi Member Rewards at 1¢, Chase Ultimate Rewards (UR) at 1¢.
59
59
  ```
60
60
 
61
- Options: `--online` or `--in-store`, `--valuation flat|points-heavy`, `--all`, `--json`, `--date`, `--country`. Run `earnrate --help` for details. To keep the wallet somewhere else, set `EARNRATE_WALLET` to its path.
61
+ Options: `--online` or `--in-store`, `--paying card|apple-pay|google-pay|paypal`, `--valuation flat|points-heavy`, `--cards`, `--member`, `--category`, `--all`, `--json`, `--date`, `--country`. Run `earnrate --help` for details. To keep the wallet somewhere else, set `EARNRATE_WALLET` to its path.
62
62
 
63
63
  `earnrate cheatsheet` prints the card to use for each everyday kind of purchase this quarter (groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores, everything else), with your caps, picks and activations as they are now. `--cards` uses cards you name instead, and `--every-card` picks from every card to show what's worth getting. It's also in the My wallet tab on [earnrate.dev](https://earnrate.dev).
64
64
 
65
- `earnrate todo` lists what your saved cards need this quarter, soonest first: bonuses to activate (with Chase's deadline), categories to pick, caps running low and welcome bonuses to finish. `--ics earnrate.ics` also writes the dates as a calendar file with reminders. My wallet on earnrate.dev shows the same list as "To do now", with an "Add the dates to your calendar" button.
65
+ `earnrate todo` lists what your saved cards need this quarter, soonest first: bonuses to activate (with Chase's deadline), categories to pick, caps running low, welcome bonuses to finish, spend bonuses you're close to, statement credits to enroll in or use, and first-year rates ending. `--ics earnrate.ics` also writes the dates as a calendar file with reminders. My wallet on earnrate.dev shows the same list as "To do now", with an "Add the dates to your calendar" button.
66
66
 
67
67
  `earnrate missed` lists the stores earnrate didn't recognize when you asked here, most asked about first. They're kept only on your computer; [open an issue](https://github.com/cwasley/earnrate/issues) to suggest one for the data.
68
68
 
@@ -153,9 +153,9 @@ Every purchase on the card counts. Once earnrate knows how much you've spent on
153
153
 
154
154
  `earnrate simulate` adds a bonus's points to a card's yearly rewards when the year's spending on it reaches the total.
155
155
 
156
- ### Store credits
156
+ ### Statement credits
157
157
 
158
- Some cards give dollars back for spending at a named store, up to a limit each period: Amex Platinum's $75 a quarter at lululemon, Amex Gold's $7 a month at Dunkin', Blue Cash Preferred's $10 a month on Disney+, Hulu or ESPN subscriptions. The purchase still earns the card's rate; the credit comes on top. Asking about the store with no cards saved lists the cards with a credit there first, "With a store credit", in dollars ("$75 back each quarter, then 1x Amex Membership Rewards"), then the usual ranking by rate. A search for a kind of purchase ("clothing") doesn't.
158
+ Some cards give dollars back for spending at a named store, up to a limit each period: Amex Platinum's $75 a quarter at lululemon, Amex Gold's $7 a month at Dunkin', Blue Cash Preferred's $10 a month on Disney+, Hulu or ESPN subscriptions. The purchase still earns the card's rate; the credit comes on top. Asking about the store with no cards saved lists the cards with a credit there first, "With a statement credit", in dollars ("$75 back each quarter, then 1x Amex Membership Rewards"), then the usual ranking by rate. A search for a kind of purchase ("clothing") doesn't.
159
159
 
160
160
  With a saved wallet, a credit counts only once you mark it enrolled (these all need enrolling with the issuer), and only while some is left this period. Given an amount, its dollars count toward that card's return and can make it the pick; without one, the answer shows what's left. Recording a purchase from an AI assistant uses it up, and `earnrate todo` reminds you to enroll and to use what's left before the period ends:
161
161
 
@@ -240,10 +240,10 @@ Use the connector (or plugin) or the local extension, not both: each keeps its o
240
240
  - `best_card_for_purchase`: which card to use, with reasons
241
241
  - `best_cards_for` and `simulate_spending`: compare every card earnrate knows, for one purchase or a year of spending, with no wallet needed
242
242
  - `resolve_merchant`: how a merchant was identified
243
- - `list_wallet`: a to-do list for your cards right now, then your cards, caps left this period, chosen categories, store credits left, welcome-bonus and spend-bonus progress and the point values you've set
243
+ - `list_wallet`: a to-do list for your cards right now, then your cards, caps left this period, chosen categories, statement credits left, welcome-bonus and spend-bonus progress and the point values you've set
244
244
  - `cheat_sheet`: the card to use for each everyday kind of purchase this quarter
245
245
  - `compare_welcome_offers`: the best public sign-up offers on cards you don't have
246
- - `record_purchase`: books a purchase against its cap, any open welcome bonus, the card's yearly spend bonuses and an enrolled store credit in your wallet file, keeping your comments
246
+ - `record_purchase`: books a purchase against its cap, any open welcome bonus, the card's yearly spend bonuses and an enrolled statement credit in your wallet file, keeping your comments
247
247
  - `search_cards`, `add_card`, `update_card`, `remove_card`, `set_memberships` and `set_point_values`: set up the wallet in conversation, including what your points are worth (memberships and point values need no cards)
248
248
 
249
249
  Both servers also offer four prompts, ready-made questions you can pick in Claude instead of typing one. None needs your cards:
@@ -303,19 +303,19 @@ Every rule in `data/cards/` carries a short quote from the issuer's own page. In
303
303
  - rules whose quote is no longer on the page, which usually means the rule changed
304
304
  - earning lines that appeared or disappeared since the last baseline, such as a new perk
305
305
 
306
- A GitHub Action runs it every Monday and opens a "Card source changes" issue when something moved. After updating a card file, run `yarn check-sources --update` to record the new baseline. Some issuers block headless browsers on shared servers; those pages are listed as "couldn't check", and running the command locally covers them. The first run needs `yarn playwright install chromium`.
306
+ A GitHub Action runs it every Monday and opens a "Card source changes" issue when something moved or a page couldn't be read. After updating a card file, run `yarn check-sources --update --cards <ids>` to record the new baseline for those cards' pages. Some issuers block headless browsers on shared servers; those pages are listed as "couldn't check", and running the command locally covers them. The first run needs `yarn playwright install chromium`.
307
307
 
308
308
  Rotating categories (Freedom Flex) are added each quarter once the issuer posts them. Until then, answers say that quarter's categories are missing rather than guessing.
309
309
 
310
310
  ## Privacy Policy
311
311
 
312
- earnrate needs no account. The local version has no telemetry; the hosted server counts requests and errors by method, tool name or page only, never what was asked. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
312
+ earnrate needs no account. The local version has no telemetry; the hosted server counts requests and errors by tool or page, with the kind of app they came from, and how many wallets are started and used each day, never what was asked. The full policy is at [earnrate.dev/privacy](https://earnrate.dev/privacy); in short:
313
313
 
314
- - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, country, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests and errors by method, tool name or page only. Connecting it starts an anonymous wallet (a recovery code, no email). It holds only what you save: card IDs and the details you gave, memberships and point values, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused, or a week if it's never used.
314
+ - **The Claude connector:** earnrate's server at mcp.earnrate.dev receives each question Claude asks it (a store or kind of purchase, and any amount, country, spending or cards you gave) and answers without keeping it; request logging is off. It counts requests and errors by tool or page, with the kind of app they came from, and how many wallets are started and used each day. Connecting it starts an anonymous wallet (a recovery code, no email). It holds only what you save: card IDs and the details you gave, memberships and point values, never card numbers, and is encrypted with its own key that only your recovery code or your connected apps can unlock. Delete it any time; it's deleted automatically after 13 months unused, or a week if it's never used.
315
315
  - **What it stores locally:** your wallet, a YAML file on your computer (`~/.earnrate/wallet.yaml` unless you set `EARNRATE_WALLET`). It lists cards by product ID, like `amex-gold`, plus your category picks, activations and how much you've spent toward caps. earnrate never asks for or stores card numbers, expiry dates, security codes or bank logins. When you ask about a store earnrate doesn't recognize, its name goes in `~/.earnrate/missed-merchants.json` (`earnrate missed` lists them; `EARNRATE_MISSED_MERCHANTS=off` keeps no list).
316
316
  - **What the local version sends:** nothing, by default. The optional merchant lookup (off unless you turn it on with your own Anthropic API key) sends only the name of a store earnrate doesn't recognize to Anthropic's API, and saves the answer in `~/.earnrate/merchant-cache.json`.
317
317
  - **AI assistants:** when you use earnrate through Claude or another assistant, that assistant's provider sees the conversation, including earnrate's answers, under its own privacy policy.
318
- - **earnrate.dev:** everything you enter on the site stays in your browser. The site counts visits with Cloudflare Web Analytics, without cookies.
318
+ - **earnrate.dev:** everything you enter on the site stays in your browser, except when you add the cheat sheet to Apple Wallet: then pass.earnrate.dev receives what the pass needs (card IDs, picks, activations, checkout names, memberships and the date) and keeps none of it. If you turn on syncing with Claude, the wallet (cards, picks, activations, memberships, point values, cap and spending totals, credits used, opening dates) goes to mcp.earnrate.dev and is stored encrypted, like the connector's; a purchase you record is sent so its totals can be updated, and only the totals are kept. Searches, stores and amounts you ask about still never leave the browser. The sign-in stays in a cookie the page's scripts can't read. The site counts which of its parts get used, by name only, and counts visits with Cloudflare Web Analytics, without cookies.
319
319
  - **Retention:** locally, your data lives only in files you control, for as long as you keep them. Delete the wallet file, or clear the site's data in your browser, to remove it. A saved wallet on mcp.earnrate.dev lasts until you delete it or leave it unused for 13 months (a week, if it's never used); the record that an app is connected goes after 90 days unused.
320
320
  - **Terms:** [earnrate.dev/terms](https://earnrate.dev/terms).
321
321
  - **Contact:** [hello@earnrate.dev](mailto:hello@earnrate.dev).
package/data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Every change to card rules, merchants, categories or currencies under `data/`. Bump `data/VERSION` (`YYYY.MM.DD`, plus `.N` for a second release on the same day) and add a section here in the same commit. Each row cites the issuer page the new value came from.
4
4
 
5
+ ## 2026.11.06.1
6
+
7
+ Wording only; no rates changed. Ten categories take `preposition: on`, so answers and pages say "on flights" rather than "at flights": `dining.fast_food`, `grocery.online`, `gas.grocery_store`, `gas.superstore`, `transit.rideshare`, `travel.flights`, `travel.car_rental`, `travel.cruise`, `home_improvement` and `other`.
8
+
9
+ ## 2026.11.06
10
+
11
+ Wording only; no rates changed. `discover-it-cash-back`'s welcome-offer note says Cashback Match doubles every rate for the first year, instead of naming the data field it's stored in.
12
+
13
+ ## 2026.11.05.2
14
+
15
+ Evidence quotes only; no rates changed. `chase-ihg-one-rewards-business`'s Expensify credit quotes the page's "Enjoy up to $100 in statement credits towards Expensify purchases each calendar year." (a link now splits the old sentence), and `venmo-credit-card`'s foreign transaction fee quotes "No foreign transaction fee" on venmo.com (a footnote number now runs into the old one). Sources: [Chase](https://creditcards.chase.com/business-credit-cards/IHG/business), [Venmo](https://venmo.com/about/creditcard/).
16
+
5
17
  ## 2026.11.05.1
6
18
 
7
19
  Welcome offers replacing the four that ended 2026-11-04, read on Amex's pages 2026-11-05; none has an end date now. Discover it's foreign transaction fee, from a Discover page that states it.
package/data/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.11.05.1
1
+ 2026.11.06.1
@@ -141,7 +141,7 @@ evidence:
141
141
  quote: 'up to a maximum accumulation of $100 in a calendar year'
142
142
  - rule: expensify
143
143
  url: https://creditcards.chase.com/business-credit-cards/IHG/business
144
- quote: 'purchases made directly with Expensify through expensify.com and the Expensify app'
144
+ quote: 'Enjoy up to $100 in statement credits towards Expensify purchases each calendar year.'
145
145
  todo:
146
146
  - "Starting January 1, 2027, $50,000 of purchases in a calendar year earns Diamond Elite status. It isn't listed as a milestone yet, because a milestone has no start date."
147
147
  - The $100 airline statement credit (after $250 on flights bought directly each calendar year) isn't modeled.
@@ -28,7 +28,7 @@ welcome_offer:
28
28
  dollars: 200
29
29
  spend: 500
30
30
  months: 3
31
- note: One-time $200 statement credit, available by applying through the card page. Existing or previous cardmembers may not be eligible if they received a new cardmember bonus for this product in the past 48 months. New cardmembers also get Cashback Match, which is ranked as a first-year rate (intro_rates) rather than counted here.
31
+ note: One-time $200 statement credit, available by applying through the card page. Existing or previous cardmembers may not be eligible if they received a new cardmember bonus for this product in the past 48 months. New cardmembers also get Cashback Match, which doubles every rate for the first year; earnrate ranks it as a first-year rate, not as part of this offer.
32
32
  caps:
33
33
  # Discover's quarterly maximum on the rotating 5% categories.
34
34
  - id: five-percent-quarterly
@@ -148,4 +148,4 @@ evidence:
148
148
  quote: 'Foreign Transaction Fee We will not charge you a foreign transaction fee.'
149
149
  - rule: foreign_transaction_fee
150
150
  url: https://venmo.com/about/creditcard/
151
- quote: 'No foreign transaction fee 2 Spend when you travel.'
151
+ quote: 'No foreign transaction fee'
@@ -21,6 +21,7 @@
21
21
  mccs: [5812, 5813, 5814]
22
22
  - id: dining.fast_food
23
23
  name: Fast food
24
+ preposition: on
24
25
  aliases:
25
26
  [fast food restaurants, coffee shop, coffee shops, coffee, cafe, cafes]
26
27
  mccs: [5814]
@@ -35,6 +36,7 @@
35
36
  mccs: [5411]
36
37
  - id: grocery.online
37
38
  name: Online grocery and delivery
39
+ preposition: on
38
40
  aliases: [grocery delivery]
39
41
 
40
42
  - id: superstore
@@ -57,8 +59,10 @@
57
59
  mccs: [5541, 5542]
58
60
  - id: gas.grocery_store
59
61
  name: Gas at grocery stores
62
+ preposition: on
60
63
  - id: gas.superstore
61
64
  name: Gas at superstores and warehouse clubs
65
+ preposition: on
62
66
  - id: ev_charging
63
67
  preposition: on
64
68
  name: EV charging
@@ -72,6 +76,7 @@
72
76
  mccs: [4111, 4112, 4131, 4784, 7523]
73
77
  - id: transit.rideshare
74
78
  name: Rideshare and taxis
79
+ preposition: on
75
80
  aliases: [rideshare, taxi, taxis]
76
81
  mccs: [4121]
77
82
 
@@ -81,6 +86,7 @@
81
86
  mccs: [4722]
82
87
  - id: travel.flights
83
88
  name: Flights
89
+ preposition: on
84
90
  aliases: [flights, airfare, airlines, flight, plane tickets]
85
91
  mccs: [4511]
86
92
  - id: travel.hotels
@@ -89,10 +95,12 @@
89
95
  mccs: [7011]
90
96
  - id: travel.car_rental
91
97
  name: Car rental
98
+ preposition: on
92
99
  aliases: [car rentals, rental cars]
93
100
  mccs: [7512]
94
101
  - id: travel.cruise
95
102
  name: Cruises
103
+ preposition: on
96
104
  aliases: [cruise]
97
105
  mccs: [4411]
98
106
 
@@ -149,6 +157,7 @@
149
157
  mccs: [5941]
150
158
  - id: home_improvement
151
159
  name: Home improvement
160
+ preposition: on
152
161
  aliases: [hardware stores, hardware store, home improvement store]
153
162
  mccs: [5200, 5211]
154
163
  - id: furniture
@@ -171,4 +180,5 @@
171
180
  - id: other
172
181
  slug: everyday-spending
173
182
  name: Everything else
183
+ preposition: on
174
184
  aliases: [everything, everything else, other]
package/dist/cli/args.js CHANGED
@@ -95,6 +95,8 @@ export function parseCliArgs(argv) {
95
95
  llm: { type: 'boolean' },
96
96
  cards: { type: 'string' },
97
97
  member: { type: 'string', multiple: true },
98
+ enrolled: { type: 'string', multiple: true },
99
+ opened: { type: 'string', multiple: true },
98
100
  help: { type: 'boolean', short: 'h' },
99
101
  },
100
102
  });
@@ -129,6 +131,25 @@ export function parseCliArgs(argv) {
129
131
  const paying = paymentOf(values.paying);
130
132
  if ('problem' in paying)
131
133
  return { kind: 'error', message: paying.problem };
134
+ const cards = cardList(values.cards);
135
+ const perCard = (flag, example) => {
136
+ const out = {};
137
+ for (const pair of values[flag] ?? []) {
138
+ const [id = '', value = ''] = pair.split('=');
139
+ if (id.trim() === '' || value.trim() === '')
140
+ return `--${flag} takes <card>=<${flag === 'opened' ? 'date' : 'credits'}>, like ${example}.`;
141
+ if (!cards?.includes(id.trim()))
142
+ return `--${flag} is for a card named with --cards; ${id.trim()} isn't one.`;
143
+ out[id.trim()] = value.trim();
144
+ }
145
+ return out;
146
+ };
147
+ const enrolled = perCard('enrolled', 'amex-platinum=lululemon');
148
+ if (typeof enrolled === 'string')
149
+ return { kind: 'error', message: enrolled };
150
+ const opened = perCard('opened', 'discover-it-cash-back=2026-03-04');
151
+ if (typeof opened === 'string')
152
+ return { kind: 'error', message: opened };
132
153
  return {
133
154
  kind: 'run',
134
155
  options: {
@@ -143,8 +164,16 @@ export function parseCliArgs(argv) {
143
164
  all: values.all ?? false,
144
165
  category: values.category ?? null,
145
166
  llm: values.llm ?? null,
146
- cards: cardList(values.cards),
167
+ cards,
147
168
  members: values.member ?? [],
169
+ enrolled: Object.fromEntries(Object.entries(enrolled).map(([id, list]) => [
170
+ id,
171
+ list
172
+ .split(',')
173
+ .map((c) => c.trim())
174
+ .filter(Boolean),
175
+ ])),
176
+ opened,
148
177
  },
149
178
  };
150
179
  }
@@ -13,20 +13,21 @@ 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, periodPhrase } from "../engine/text.js";
16
+ import { currencyName, dollarsAndCents, listed, 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
 
20
20
  Reads card statements (Amex and Chase activity CSVs) and reports:
21
21
  - what the same purchases would have earned on earnrate's picks
22
22
  - where the issuer coded a merchant differently than earnrate assumes
23
- - the cap usage it would record for the current period
23
+ - the cap usage, welcome-bonus and spend-bonus spending, and statement
24
+ credits used that it would record for the current period
24
25
 
25
26
  Name several statements, each with its card, to see one report across them.
26
27
 
27
28
  Nothing is written unless you pass --apply, and then only cap totals,
28
- welcome-bonus spending and a fingerprint of each recorded purchase, so
29
- importing twice never counts twice.
29
+ welcome-bonus and spend-bonus spending, statement credits used and a
30
+ fingerprint of each recorded purchase, so importing twice never counts twice.
30
31
  The statements themselves are never stored.
31
32
 
32
33
  Options:
@@ -66,10 +67,7 @@ function valuationNote(catalog, wallet, cardIds, profile) {
66
67
  if (values.size === 0)
67
68
  return 'Cash back counted as cash.';
68
69
  const parts = [...values].map(([id, cents]) => `${String(cents)}¢ for ${currencyName(catalog, id)}`);
69
- const list = parts.length > 1
70
- ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`
71
- : (parts[0] ?? '');
72
- return `Points valued at ${list}${profile ? ` (the ${profile} profile)` : ', as in your wallet'}.`;
70
+ return `Points valued at ${listed(parts)}${profile ? ` (the ${profile} profile)` : ', as in your wallet'}.`;
73
71
  }
74
72
  function formatAnalysis(a, labelOf, valuation, categoryName) {
75
73
  const several = a.byCard.length > 1;
@@ -248,21 +246,26 @@ export async function runImport(argv, io) {
248
246
  ...(milestoneEntries.length > 0
249
247
  ? [`${String(milestoneEntries.length)} toward spend bonuses`]
250
248
  : []),
249
+ ...(analysis.credits.length > 0
250
+ ? [`${String(analysis.credits.length)} against statement credits`]
251
+ : []),
251
252
  ];
252
253
  if (!values.apply || toRecord.length === 0) {
253
254
  io.stdout(values.apply || toRecord.length === 0
254
255
  ? 'Nothing was written.\n'
255
- : `Nothing was written. Run again with --apply to record ${toRecord.join(' and ')}.\n`);
256
+ : `Nothing was written. Run again with --apply to record ${listed(toRecord)}.\n`);
256
257
  return 0;
257
258
  }
258
- await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses, analysis.milestones);
259
+ await writeProgress(walletFile, analysis.bookings.map((e) => e.booking), analysis.bonuses, analysis.milestones, analysis.credits.map((e) => e.booking));
259
260
  for (const entry of analysis.bookings)
260
261
  recorded.add(fingerprint(entry));
262
+ for (const entry of analysis.credits)
263
+ recorded.add(fingerprint(entry, 'credit'));
261
264
  for (const entry of bonusEntries)
262
265
  recorded.add(fingerprint(entry, 'bonus'));
263
266
  for (const entry of milestoneEntries)
264
267
  recorded.add(fingerprint(entry, 'milestone'));
265
268
  await writeFileAtomic(ledgerFile, `${JSON.stringify({ version: 1, recorded: [...recorded] }, null, 2)}\n`);
266
- io.stdout(`Recorded ${toRecord.join(' and ')} in ${displayPath(walletFile)}.\n`);
269
+ io.stdout(`Recorded ${listed(toRecord)} in ${displayPath(walletFile)}.\n`);
267
270
  return 0;
268
271
  }
package/dist/cli/run.js CHANGED
@@ -70,6 +70,11 @@ Options:
70
70
  ${PAYING_HELP}
71
71
  --cards <ids> Answer for these cards instead of your wallet, comma
72
72
  separated: --cards amex-gold,chase-prime-visa.
73
+ --enrolled <card>=<credits>
74
+ For a named card, statement credits you've enrolled
75
+ in: --enrolled amex-platinum=lululemon. Repeat per card.
76
+ --opened <card>=<date> For a named card, the day it opened (YYYY-MM-DD), for
77
+ a first-year rate. Repeat per card.
73
78
  --member <name> A membership you have, in place of the saved ones.
74
79
  --category <id> For a merchant earnrate doesn't know: its category,
75
80
  e.g. grocery.supermarket. Ignored for known merchants.
@@ -106,7 +111,11 @@ export async function cardsToUse(catalog, file, io, named) {
106
111
  if (named.cards !== null) {
107
112
  const saved = await savedSettings(io);
108
113
  const wallet = walletFromCards(catalog, {
109
- cards: named.cards.map((id) => ({ id })),
114
+ cards: named.cards.map((id) => ({
115
+ id,
116
+ ...(named.enrolled?.[id] ? { enrolled: named.enrolled[id] } : {}),
117
+ ...(named.opened?.[id] ? { opened: named.opened[id] } : {}),
118
+ })),
110
119
  memberships: named.members.length > 0 ? named.members : saved.memberships,
111
120
  }, io.today);
112
121
  if (typeof wallet === 'string') {
package/dist/cli/todo.js CHANGED
@@ -9,8 +9,9 @@ import { displayPath, readWallet, RUNTIME_ERROR, USAGE_ERROR, } from "./run.js";
9
9
  const TODO_HELP = `Usage: ${CLI_NAME} todo [--ics <file>] [--date <YYYY-MM-DD>]
10
10
 
11
11
  What your saved cards need this period, soonest first: bonuses to activate,
12
- categories to pick, spending caps running low, welcome bonuses to finish, and
13
- rotating categories the issuer hasn't posted yet.
12
+ categories to pick, spending caps running low, welcome bonuses to finish,
13
+ spend bonuses you're close to, statement credits to enroll in or use, first-year
14
+ rates ending, and rotating categories the issuer hasn't posted yet.
14
15
 
15
16
  Options:
16
17
  --ics <file> Also write the dated items as a calendar file to import
@@ -19,12 +19,18 @@ const CARD_OPTIONS = `Options:
19
19
  card is ranked at its standard rates.
20
20
  --activated <rules> Rules you've activated with the issuer, comma
21
21
  separated. Replaces the current list.
22
- --enrolled <credits> Merchant credits you've enrolled in with the
22
+ --enrolled <credits> Statement credits you've enrolled in with the
23
23
  issuer, comma separated, e.g. --enrolled lululemon.
24
24
  Replaces the current list.
25
25
  --credit-used <credit>=<dollars>
26
26
  How much of a credit you've received this period,
27
27
  e.g. --credit-used lululemon=40. Repeat for each.
28
+ --cap-spent <cap>=<dollars>
29
+ Spending toward a cap so far this period, e.g.
30
+ --cap-spent us-supermarkets=1200. Repeat for each.
31
+ --milestone-spent <bonus>=<dollars>
32
+ Spending this year toward a spend bonus, e.g.
33
+ --milestone-spent spend-15k=9000. Repeat for each.
28
34
  --bonus-opened <date> Track a welcome bonus: the day the account opened
29
35
  (YYYY-MM-DD). Spending needed and the deadline
30
36
  default to the card's published offer.
@@ -53,15 +59,17 @@ ${CARD_OPTIONS}
53
59
  update: `Usage: ${CLI_NAME} update <card-id> [options]
54
60
 
55
61
  Changes a card in your wallet: picks for this period, label, anniversary,
56
- opening date, activations, merchant credits or welcome bonus. Picks for the
57
- current period are merged with ones already set.
62
+ opening date, activations, spending toward caps and spend bonuses, statement
63
+ credits or welcome bonus. Picks for the current period are merged with ones
64
+ already set.
58
65
 
59
66
  ${CARD_OPTIONS}
60
67
  `,
61
68
  remove: `Usage: ${CLI_NAME} remove <card-id>
62
69
 
63
- Removes a card from your wallet, with its tracked cap usage and bonus
64
- progress. Your memberships and point values stay.
70
+ Removes a card from your wallet, with its tracked cap usage, welcome-bonus
71
+ progress, spend-bonus spending and statement credits used. Your memberships
72
+ and point values stay.
65
73
  `,
66
74
  memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
67
75
 
@@ -143,6 +151,8 @@ export async function runWalletCommand(command, argv, io) {
143
151
  activated: { type: 'string' },
144
152
  enrolled: { type: 'string' },
145
153
  'credit-used': { type: 'string', multiple: true },
154
+ 'cap-spent': { type: 'string', multiple: true },
155
+ 'milestone-spent': { type: 'string', multiple: true },
146
156
  'bonus-opened': { type: 'string' },
147
157
  'bonus-spent': { type: 'string' },
148
158
  'bonus-spend': { type: 'string' },
@@ -173,7 +183,14 @@ export async function runWalletCommand(command, argv, io) {
173
183
  values.opened !== undefined ||
174
184
  values.activated !== undefined ||
175
185
  values.enrolled !== undefined ||
176
- values['credit-used'] !== undefined;
186
+ values['credit-used'] !== undefined ||
187
+ values['cap-spent'] !== undefined ||
188
+ values['milestone-spent'] !== undefined ||
189
+ values['bonus-opened'] !== undefined ||
190
+ values['bonus-spent'] !== undefined ||
191
+ values['bonus-spend'] !== undefined ||
192
+ values['bonus-by'] !== undefined ||
193
+ values['no-bonus'] !== undefined;
177
194
  if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
178
195
  return usage(`${CLI_NAME} ${command} doesn't take card options.`);
179
196
  }
@@ -277,17 +294,33 @@ export async function runWalletCommand(command, argv, io) {
277
294
  .map((r) => r.trim())
278
295
  .filter((r) => r !== '');
279
296
  }
280
- if (values['credit-used'] !== undefined) {
281
- const used = {};
282
- for (const pair of values['credit-used']) {
283
- const [credit = '', dollars] = pair.split('=');
284
- const n = dollarsFlag('credit-used', dollars);
285
- if (credit.trim() === '' || n === undefined || typeof n === 'string')
286
- return usage('--credit-used takes <credit>=<dollars>, like lululemon=40.');
287
- used[credit.trim()] = n;
297
+ // --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
298
+ const dollarPairs = (flag, example) => {
299
+ const pairs = values[flag];
300
+ if (pairs === undefined)
301
+ return undefined;
302
+ const out = {};
303
+ for (const pair of pairs) {
304
+ const [id = '', dollars] = pair.split('=');
305
+ const n = dollarsFlag(flag, dollars);
306
+ if (id.trim() === '' || n === undefined || typeof n === 'string')
307
+ return `--${flag} takes <id>=<dollars>, like ${example}.`;
308
+ out[id.trim()] = n;
288
309
  }
289
- input.creditUsed = used;
290
- }
310
+ return out;
311
+ };
312
+ const creditUsed = dollarPairs('credit-used', 'lululemon=40');
313
+ const capSpent = dollarPairs('cap-spent', 'us-supermarkets=1200');
314
+ const milestoneSpent = dollarPairs('milestone-spent', 'spend-15k=9000');
315
+ for (const result of [creditUsed, capSpent, milestoneSpent])
316
+ if (typeof result === 'string')
317
+ return usage(result);
318
+ if (typeof creditUsed === 'object')
319
+ input.creditUsed = creditUsed;
320
+ if (typeof capSpent === 'object')
321
+ input.capSpent = capSpent;
322
+ if (typeof milestoneSpent === 'object')
323
+ input.milestoneSpent = milestoneSpent;
291
324
  if (values['no-bonus']) {
292
325
  input.bonus = null;
293
326
  }
@@ -6,7 +6,8 @@ import { periodKey } from "./periods.js";
6
6
  import { ABROAD, recommend } from "./recommend.js";
7
7
  import { checklist } from "./checklist.js";
8
8
  import { setupNotes } from "./setup.js";
9
- import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
9
+ import { capitalize, CREDITS_LEFT_OUT, feeText, heldName, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
10
+ const midSentence = (item) => item.phrase ?? ('merchant' in item ? item.label : item.label.toLowerCase());
10
11
  // The everyday kinds of purchase: the cheat sheet's rows and the site's
11
12
  // quick picks.
12
13
  export const EVERYDAY_PURCHASES = [
@@ -16,7 +17,11 @@ export const EVERYDAY_PURCHASES = [
16
17
  { label: 'Amazon', merchant: 'amazon' },
17
18
  { label: 'Costco', merchant: 'costco' },
18
19
  { label: 'Travel', category: 'travel' },
19
- { label: 'Streaming (Netflix)', merchant: 'netflix' },
20
+ {
21
+ label: 'Streaming (Netflix)',
22
+ merchant: 'netflix',
23
+ phrase: 'streaming (Netflix)',
24
+ },
20
25
  { label: 'Drugstores', category: 'drugstore' },
21
26
  { label: 'Everything else', category: 'other' },
22
27
  ];
@@ -43,7 +48,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
43
48
  // The card that earns the most, even when a welcome bonus would take the
44
49
  // pick for one purchase: the sheet is for the whole quarter.
45
50
  const top = rec.decision ? rec.ranked[0] : undefined;
46
- ranked.push({ label: item.label, cards: rec.ranked });
51
+ ranked.push({ phrase: midSentence(item), cards: rec.ranked });
47
52
  return {
48
53
  label: item.label,
49
54
  cardId: top?.cardId ?? null,
@@ -58,22 +63,20 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
58
63
  const status = bonusStatus(catalog, wallet, held, date);
59
64
  if (status?.state !== 'open')
60
65
  return [];
61
- const name = held.checkout_label ??
62
- catalog.cards.find((c) => c.id === held.id)?.name ??
63
- held.id;
66
+ const name = heldName(catalog, held);
64
67
  // Rows where the bonus card matches the row's card, or is close to it
65
68
  // (as single answers mention it).
66
69
  const matches = [];
67
70
  const near = [];
68
- for (const { label, cards } of ranked) {
71
+ for (const { phrase, cards } of ranked) {
69
72
  const best = cards[0]?.effectiveReturnPct;
70
73
  const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
71
74
  if (best === undefined || own === undefined)
72
75
  continue;
73
76
  if (best - own < EPSILON)
74
- matches.push(label.toLowerCase());
77
+ matches.push(phrase);
75
78
  else if (isClose(own, best))
76
- near.push(label.toLowerCase());
79
+ near.push(phrase);
77
80
  }
78
81
  const where = [
79
82
  ...(matches.length > 0
@@ -83,7 +86,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
83
86
  ];
84
87
  return [
85
88
  `Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
86
- ? `It ${where.join(' and ')}, so those are the places to use it.`
89
+ ? `It ${where.join(' and ')}, so ${matches.length + near.length === 1 ? "that's the place" : 'those are the places'} to use it.`
87
90
  : 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
88
91
  ];
89
92
  });
@@ -1,4 +1,4 @@
1
- import { currentPeriodEnd, daysBetween } from "./periods.js";
1
+ import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
2
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";
@@ -24,7 +24,7 @@ shortName) {
24
24
  items.push({
25
25
  kind: 'activate',
26
26
  cardId: card.id,
27
- text: `Activate ${possessive(name)} bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
27
+ text: `Activate ${possessive(name)} bonus ${a.where}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
28
28
  due: a.activateBy ?? (a.from !== null && a.from > today ? a.from : a.to),
29
29
  });
30
30
  }
@@ -151,8 +151,8 @@ shortName) {
151
151
  a.cardId.localeCompare(b.cardId));
152
152
  }
153
153
  // An all-day calendar event for each dated action (activations, picks,
154
- // welcome-bonus deadlines, credits to use), with a reminder two days before. Built on the
155
- // device: nothing is sent anywhere.
154
+ // welcome-bonus deadlines, credits to use), with a reminder two days before.
155
+ // Built on the device: nothing is sent anywhere.
156
156
  export function checklistCalendar(items, stamp) {
157
157
  const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
158
158
  const fold = (line) => {
@@ -162,11 +162,6 @@ export function checklistCalendar(items, stamp) {
162
162
  return parts.join('\r\n ');
163
163
  };
164
164
  const day = (d) => d.replace(/-/g, '');
165
- const next = (d) => {
166
- const t = new Date(`${d}T00:00:00Z`);
167
- t.setUTCDate(t.getUTCDate() + 1);
168
- return t.toISOString().slice(0, 10);
169
- };
170
165
  const events = items
171
166
  .filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
172
167
  .flatMap((i) => {
@@ -176,7 +171,7 @@ export function checklistCalendar(items, stamp) {
176
171
  `UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
177
172
  `DTSTAMP:${stamp}`,
178
173
  `DTSTART;VALUE=DATE:${day(due)}`,
179
- `DTEND;VALUE=DATE:${day(next(due))}`,
174
+ `DTEND;VALUE=DATE:${day(addDays(due, 1))}`,
180
175
  fold(`SUMMARY:${escape(i.text)}`),
181
176
  'BEGIN:VALARM',
182
177
  'TRIGGER:-P2D',
@@ -15,8 +15,8 @@ export function creditCovers(catalog, credit, merchant) {
15
15
  return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
16
16
  }
17
17
  // A credit that needs enrolling counts only once the wallet says it's done.
18
- export const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
- export function creditState(catalog, credit, held, date) {
18
+ const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
19
+ function creditState(catalog, credit, held, date) {
20
20
  const anniversary = held.anniversary ?? null;
21
21
  const period = periodKey(credit.period, date, anniversary);
22
22
  const usage = held.credit_usage?.[credit.id];
@@ -3,7 +3,7 @@ import { EPSILON } from "./compare.js";
3
3
  import { introCovers, introWindows, withIntro, } from "./intro.js";
4
4
  import { inEffect, periodKey } from "./periods.js";
5
5
  // PayPal checkout happens online, so paying through it says the channel.
6
- export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
6
+ const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
7
7
  export function purchaseContext(catalog, wallet, merchant, category, purchase) {
8
8
  const payment = purchase.payment ?? null;
9
9
  return {