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.
- package/README.md +10 -10
- package/data/CHANGELOG.md +12 -0
- package/data/VERSION +1 -1
- package/data/cards/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +1 -1
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/dist/cli/args.js +30 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/run.js +10 -1
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +49 -16
- package/dist/engine/cheat-sheet.js +13 -10
- package/dist/engine/checklist.js +5 -10
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +7 -3
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +3 -7
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +19 -17
- package/dist/engine/record.js +1 -1
- package/dist/engine/rule-text.js +71 -23
- package/dist/engine/setup.js +4 -0
- package/dist/engine/text.js +23 -6
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +32 -23
- package/dist/format/card-text.js +6 -5
- package/dist/format/cards.js +2 -2
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +2 -2
- package/dist/format/wallet.js +5 -5
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +11 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +50 -42
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +205 -148
- package/dist/mcp/wallet-api.js +78 -0
- package/dist/schema/card.js +6 -10
- 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
|
|
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
|
-
###
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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: '
|
|
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
|
|
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
|
|
151
|
+
quote: 'No foreign transaction fee'
|
package/data/categories.yaml
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/cli/import.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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) => ({
|
|
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,
|
|
13
|
-
|
|
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
|
package/dist/cli/wallet.js
CHANGED
|
@@ -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>
|
|
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,
|
|
57
|
-
current period are merged with ones
|
|
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
|
|
64
|
-
progress
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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({
|
|
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
|
|
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 {
|
|
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(
|
|
77
|
+
matches.push(phrase);
|
|
75
78
|
else if (isClose(own, best))
|
|
76
|
-
near.push(
|
|
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
|
});
|
package/dist/engine/checklist.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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(
|
|
174
|
+
`DTEND;VALUE=DATE:${day(addDays(due, 1))}`,
|
|
180
175
|
fold(`SUMMARY:${escape(i.text)}`),
|
|
181
176
|
'BEGIN:VALARM',
|
|
182
177
|
'TRIGGER:-P2D',
|
package/dist/engine/credits.js
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
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];
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|