earnrate 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/CHANGELOG.md +16 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-delta-skymiles-gold.yaml +7 -8
- package/data/cards/amex-delta-skymiles-platinum.yaml +7 -8
- package/data/cards/amex-delta-skymiles-reserve.yaml +9 -9
- package/data/cards/amex-marriott-bonvoy-business.yaml +7 -8
- package/data/cards/discover-it-cash-back.yaml +8 -5
- package/data/merchants/seed.yaml +3 -1
- package/dist/cli/wallet.js +1 -1
- package/dist/engine/cheat-sheet.js +4 -3
- package/dist/engine/checklist.js +19 -16
- package/dist/engine/explore.js +2 -2
- package/dist/engine/recommend.js +30 -19
- package/dist/engine/record.js +1 -0
- package/dist/engine/rotation.js +2 -1
- package/dist/engine/rule-text.js +27 -10
- package/dist/engine/setup.js +3 -3
- package/dist/engine/simulate.js +4 -4
- package/dist/engine/text.js +6 -2
- package/dist/format/cards.js +6 -2
- package/dist/format/wallet-yaml.js +11 -15
- package/dist/format/wallet.js +12 -8
- package/dist/mcp/schemas.js +1 -0
- package/dist/mcp/tools/answers.js +2 -1
- package/dist/mcp/tools/wallet.js +2 -2
- package/dist/schema/merchant.js +3 -0
- package/package.json +1 -1
package/data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
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.05.1
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
| Card | Field | Was | Now | Sources |
|
|
10
|
+
| ----------------------------- | ------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| amex-delta-skymiles-gold | `welcome_offer` | as high as 80,000 miles + $250 credit after $3,000 in 6 months, ending 2026-11-04 | as high as 80,000 miles after $2,000 in 6 months | [1](https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-gold-american-express-card/25330-10-0#offer-terms) · [2](https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-gold-american-express-card/) |
|
|
12
|
+
| amex-delta-skymiles-platinum | `welcome_offer` | as high as 90,000 miles + $300 credit after $4,000 in 6 months, ending 2026-11-04 | as high as 90,000 miles after $3,000 in 6 months | [1](https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-platinum-american-express-card/25330-10-0#offer-terms) · [2](https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-platinum-american-express-card/) |
|
|
13
|
+
| amex-delta-skymiles-reserve | `welcome_offer` | 50,000 miles + 2 Comfort Flight Certificates after $10,000 in 6 months, ending 2026-11-04 | as high as 125,000 miles after $5,000 in 6 months | [1](https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms) · [2](https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-reserve-american-express-card/) |
|
|
14
|
+
| amex-marriott-bonvoy-business | `welcome_offer` | 100,000 points + Free Night Award after $8,000 in 6 months, ending 2026-11-04 | 75,000 points after $6,000 in 6 months | [1](https://www.americanexpress.com/en-us/credit-cards/apply/terms/business/prospect/marriott-bonvoy-business-american-express-card/45094-9-0#offer-terms) · [2](https://www.americanexpress.com/us/credit-cards/business/business-credit-cards/amex-marriott-bonvoy-business-credit-card/) |
|
|
15
|
+
| discover-it-cash-back | `foreign_transaction_fee` | unknown | 0 ("Discover® has no foreign transaction fee") | [1](https://www.discover.com/credit-cards/card-smarts/best-international-travel-credit-card/) |
|
|
16
|
+
|
|
17
|
+
## 2026.11.05
|
|
18
|
+
|
|
19
|
+
Wording only; no rates changed. `walmart-plus`, `peloton-membership` and `uber-one` take `preposition: on`, so their credits read "on Walmart+ membership" rather than "at".
|
|
20
|
+
|
|
5
21
|
## 2026.11.04.15
|
|
6
22
|
|
|
7
23
|
Merchant statement credits on every card whose issuer publishes them: 73 new credits on 31 cards, each quoted from the issuer's own pages (read 2026-11-04). With the three already entered, 76 credits on 32 cards. Credits a cardholder must pick a merchant for, ones once every several years, benefits that aren't spending, and the kinds `docs/feature-gaps.md` lists under "Merchant credits the schema can't express" aren't entered. No earn rates changed.
|
package/data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.11.
|
|
1
|
+
2026.11.05.1
|
|
@@ -5,7 +5,7 @@ color: '#c9a24f'
|
|
|
5
5
|
issuer: amex
|
|
6
6
|
network: amex
|
|
7
7
|
currency: delta-skymiles
|
|
8
|
-
last_verified: 2026-11-
|
|
8
|
+
last_verified: 2026-11-05
|
|
9
9
|
verified_by: claude
|
|
10
10
|
source_urls:
|
|
11
11
|
- https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-gold-american-express-card/
|
|
@@ -17,12 +17,11 @@ first_year_fee: 0
|
|
|
17
17
|
foreign_transaction_fee: 0
|
|
18
18
|
welcome_offer:
|
|
19
19
|
points: 80000
|
|
20
|
-
spend:
|
|
20
|
+
spend: 2000
|
|
21
21
|
months: 6
|
|
22
|
-
valid_to: 2026-11-04
|
|
23
22
|
varies: true
|
|
24
23
|
not_with: [amex-delta-skymiles-platinum, amex-delta-skymiles-reserve]
|
|
25
|
-
note: 'The public offer is "as high as" 80,000 bonus miles
|
|
24
|
+
note: 'The public offer is "as high as" 80,000 bonus miles; Amex shows the exact miles (which may be lower, or no offer) after you apply and before you accept. Amex: "You may not be eligible for a welcome offer if you have or have had this Card or previous versions of this Card, or if you have or have had the Delta SkyMiles® Platinum American Express Card, the Delta SkyMiles® Reserve American Express Card or previous versions of these Cards." Amex may also decline based on your card history.'
|
|
26
25
|
rules:
|
|
27
26
|
- id: restaurants
|
|
28
27
|
match: { categories: [dining] }
|
|
@@ -116,16 +115,16 @@ evidence:
|
|
|
116
115
|
quote: 'will be $0 for the first year of Card Membership'
|
|
117
116
|
- rule: welcome_offer
|
|
118
117
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-gold-american-express-card/25330-10-0#offer-terms
|
|
119
|
-
quote: 'As High As 80,000 Bonus Miles
|
|
118
|
+
quote: 'As High As 80,000 Bonus Miles'
|
|
120
119
|
- rule: welcome_offer
|
|
121
120
|
url: https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-gold-american-express-card/
|
|
122
|
-
quote: 'after you spend $
|
|
121
|
+
quote: 'after you spend $2,000 in purchases on your new Card within the first 6 months'
|
|
123
122
|
- rule: welcome_offer
|
|
124
123
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-gold-american-express-card/25330-10-0#offer-terms
|
|
125
|
-
quote: '
|
|
124
|
+
quote: 'You can earn these bonus miles after you make $2,000 or more'
|
|
126
125
|
- rule: not_with
|
|
127
126
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-gold-american-express-card/25330-10-0#offer-terms
|
|
128
|
-
quote: 'if you have or have had
|
|
127
|
+
quote: 'if you have or have had the Delta SkyMiles® Platinum American Express Card, the Delta SkyMiles® Reserve American Express Card'
|
|
129
128
|
- rule: foreign_transaction_fee
|
|
130
129
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-gold-american-express-card/25330-10-0#offer-terms
|
|
131
130
|
quote: 'Foreign Transaction None Penalty Fees'
|
|
@@ -5,7 +5,7 @@ color: '#a9abae'
|
|
|
5
5
|
issuer: amex
|
|
6
6
|
network: amex
|
|
7
7
|
currency: delta-skymiles
|
|
8
|
-
last_verified: 2026-11-
|
|
8
|
+
last_verified: 2026-11-05
|
|
9
9
|
verified_by: claude
|
|
10
10
|
source_urls:
|
|
11
11
|
- https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-platinum-american-express-card/
|
|
@@ -16,12 +16,11 @@ annual_fee: 350
|
|
|
16
16
|
foreign_transaction_fee: 0
|
|
17
17
|
welcome_offer:
|
|
18
18
|
points: 90000
|
|
19
|
-
spend:
|
|
19
|
+
spend: 3000
|
|
20
20
|
months: 6
|
|
21
|
-
valid_to: 2026-11-04
|
|
22
21
|
varies: true
|
|
23
22
|
not_with: [amex-delta-skymiles-reserve]
|
|
24
|
-
note: 'The public offer is "as high as" 90,000 bonus miles
|
|
23
|
+
note: 'The public offer is "as high as" 90,000 bonus miles; Amex shows the exact miles (which may be lower, or no offer) after you apply and before you accept. Amex: "You may not be eligible for a welcome offer if you have or have had this Card or previous versions of this Card or if you have or have had the Delta SkyMiles® Reserve American Express Card or previous versions of these Cards." Amex may also decline based on your card history.'
|
|
25
24
|
rules:
|
|
26
25
|
- id: delta
|
|
27
26
|
match: { merchants: [delta, delta-vacations, delta-stays] }
|
|
@@ -118,16 +117,16 @@ evidence:
|
|
|
118
117
|
quote: 'Annual Fee $350 Transaction Fees'
|
|
119
118
|
- rule: welcome_offer
|
|
120
119
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-platinum-american-express-card/25330-10-0#offer-terms
|
|
121
|
-
quote: 'As High As 90,000 Bonus Miles
|
|
120
|
+
quote: 'As High As 90,000 Bonus Miles'
|
|
122
121
|
- rule: welcome_offer
|
|
123
122
|
url: https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-platinum-american-express-card/
|
|
124
|
-
quote: 'after you spend $
|
|
123
|
+
quote: 'after you spend $3,000 in purchases on your new Card within the first 6 months'
|
|
125
124
|
- rule: welcome_offer
|
|
126
125
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-platinum-american-express-card/25330-10-0#offer-terms
|
|
127
|
-
quote: '
|
|
126
|
+
quote: 'You can earn these bonus miles after you make $3,000 or more'
|
|
128
127
|
- rule: not_with
|
|
129
128
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-platinum-american-express-card/25330-10-0#offer-terms
|
|
130
|
-
quote: 'if you have or have had
|
|
129
|
+
quote: 'if you have or have had the Delta SkyMiles® Reserve American Express Card or previous versions of these Cards'
|
|
131
130
|
- rule: foreign_transaction_fee
|
|
132
131
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-platinum-american-express-card/25330-10-0#offer-terms
|
|
133
132
|
quote: 'Foreign Transaction None Penalty Fees'
|
|
@@ -5,7 +5,7 @@ color: '#2b2d33'
|
|
|
5
5
|
issuer: amex
|
|
6
6
|
network: amex
|
|
7
7
|
currency: delta-skymiles
|
|
8
|
-
last_verified: 2026-11-
|
|
8
|
+
last_verified: 2026-11-05
|
|
9
9
|
verified_by: claude
|
|
10
10
|
source_urls:
|
|
11
11
|
- https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-reserve-american-express-card/
|
|
@@ -14,11 +14,11 @@ base_rate: 1
|
|
|
14
14
|
annual_fee: 650
|
|
15
15
|
foreign_transaction_fee: 0
|
|
16
16
|
welcome_offer:
|
|
17
|
-
points:
|
|
18
|
-
spend:
|
|
17
|
+
points: 125000
|
|
18
|
+
spend: 5000
|
|
19
19
|
months: 6
|
|
20
|
-
|
|
21
|
-
note: '
|
|
20
|
+
varies: true
|
|
21
|
+
note: 'The public offer is "as high as" 125,000 bonus miles; Amex shows the exact miles (which may be lower, or no offer) after you apply and before you accept. Amex: "You may not be eligible for a welcome offer if you have or have had this Card or previous versions of this Card." Amex may also decline based on your card history.'
|
|
22
22
|
rules:
|
|
23
23
|
- id: delta
|
|
24
24
|
match: { merchants: [delta, delta-vacations, delta-stays] }
|
|
@@ -80,16 +80,16 @@ evidence:
|
|
|
80
80
|
quote: 'The annual fee for the Delta SkyMiles® Reserve American Express Card is $650.'
|
|
81
81
|
- rule: welcome_offer
|
|
82
82
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms
|
|
83
|
-
quote: '
|
|
83
|
+
quote: 'As High As 125,000 Bonus Miles'
|
|
84
84
|
- rule: welcome_offer
|
|
85
85
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms
|
|
86
|
-
quote: 'after you make $
|
|
86
|
+
quote: 'You can earn these bonus miles after you make $5,000 or more'
|
|
87
87
|
- rule: welcome_offer
|
|
88
88
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms
|
|
89
89
|
quote: 'within your first 6 months of Card Membership'
|
|
90
90
|
- rule: welcome_offer
|
|
91
|
-
url: https://www.americanexpress.com/
|
|
92
|
-
quote: '
|
|
91
|
+
url: https://www.americanexpress.com/us/credit-cards/card/delta-skymiles-reserve-american-express-card/
|
|
92
|
+
quote: 'after you spend $5,000 in purchases on your new Card within the first 6 months'
|
|
93
93
|
- rule: foreign_transaction_fee
|
|
94
94
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms
|
|
95
95
|
quote: 'Foreign Transaction None Penalty Fees'
|
|
@@ -6,7 +6,7 @@ issuer: amex
|
|
|
6
6
|
network: amex
|
|
7
7
|
currency: marriott-bonvoy
|
|
8
8
|
business: true
|
|
9
|
-
last_verified: 2026-11-
|
|
9
|
+
last_verified: 2026-11-05
|
|
10
10
|
verified_by: claude
|
|
11
11
|
source_urls:
|
|
12
12
|
- https://www.americanexpress.com/us/credit-cards/business/business-credit-cards/amex-marriott-bonvoy-business-credit-card/
|
|
@@ -15,11 +15,10 @@ base_rate: 2
|
|
|
15
15
|
annual_fee: 125
|
|
16
16
|
foreign_transaction_fee: 0
|
|
17
17
|
welcome_offer:
|
|
18
|
-
points:
|
|
19
|
-
spend:
|
|
18
|
+
points: 75000
|
|
19
|
+
spend: 6000
|
|
20
20
|
months: 6
|
|
21
|
-
|
|
22
|
-
note: 'Plus a Free Night Award (up to 50,000 points), which earnrate does not value. Amex: "You may not be eligible to receive a welcome offer if you have or have had this Card or the Starwood Preferred Guest® Business Credit Card from American Express or previous versions of these Cards," and may also decline based on your card history. Also not available to anyone who had a Marriott business card from Chase in the last 30 days, opened the Marriott Bonvoy Bountiful, Boundless or Bold card from Chase in the last 90 days, or got a new Card Member bonus on one of those Chase cards in the last 24 months.'
|
|
21
|
+
note: 'Amex: "You may not be eligible to receive a welcome offer if you have or have had this Card or the Starwood Preferred Guest® Business Credit Card from American Express or previous versions of these Cards," and may also decline based on your card history. Also not available to anyone who had a Marriott business card from Chase in the last 30 days, opened the Marriott Bonvoy Bountiful, Boundless or Bold card from Chase in the last 90 days, or got a new Card Member bonus on one of those Chase cards in the last 24 months.'
|
|
23
22
|
rules:
|
|
24
23
|
- id: marriott-hotels
|
|
25
24
|
match: { merchants: [marriott] }
|
|
@@ -102,13 +101,13 @@ evidence:
|
|
|
102
101
|
quote: 'Foreign Transaction None Penalty Fees'
|
|
103
102
|
- rule: welcome_offer
|
|
104
103
|
url: https://www.americanexpress.com/us/credit-cards/business/business-credit-cards/amex-marriott-bonvoy-business-credit-card/
|
|
105
|
-
quote: 'Earn
|
|
104
|
+
quote: 'Earn 75,000 Marriott Bonvoy® Bonus Points'
|
|
106
105
|
- rule: welcome_offer
|
|
107
106
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/business/prospect/marriott-bonvoy-business-american-express-card/45094-9-0#offer-terms
|
|
108
|
-
quote: '
|
|
107
|
+
quote: 'You can earn 75,000 Marriott Bonvoy® bonus points after you complete $6,000 or more'
|
|
109
108
|
- rule: welcome_offer
|
|
110
109
|
url: https://www.americanexpress.com/en-us/credit-cards/apply/terms/business/prospect/marriott-bonvoy-business-american-express-card/45094-9-0#offer-terms
|
|
111
|
-
quote: '
|
|
110
|
+
quote: 'within your first 6 months of Card Membership'
|
|
112
111
|
todo:
|
|
113
112
|
- The welcome offer ends 11/04/2026; re-check the card page for the next one.
|
|
114
113
|
- The welcome offer's Free Night Award isn't valued; a welcome offer holds only points.
|
|
@@ -6,7 +6,7 @@ color: '#e8751a'
|
|
|
6
6
|
issuer: discover
|
|
7
7
|
network: discover
|
|
8
8
|
currency: cash
|
|
9
|
-
last_verified: 2026-11-
|
|
9
|
+
last_verified: 2026-11-05
|
|
10
10
|
verified_by: claude
|
|
11
11
|
source_urls:
|
|
12
12
|
- https://www.discover.com/credit-cards/cash-back/it-card/
|
|
@@ -18,11 +18,12 @@ source_urls:
|
|
|
18
18
|
- https://www.discover.com/credit-cards/cash-back/cashback-bonus.html
|
|
19
19
|
# The calendar page's "See our FAQs".
|
|
20
20
|
- https://www.discover.com/credit-cards/card-smarts/using-cashback-bonus/
|
|
21
|
+
# Discover's travel-card article: the only Discover page that states the
|
|
22
|
+
# foreign transaction fee (the application terms' fee table has no row).
|
|
23
|
+
- https://www.discover.com/credit-cards/card-smarts/best-international-travel-credit-card/
|
|
21
24
|
base_rate: 1
|
|
22
25
|
annual_fee: 0
|
|
23
|
-
|
|
24
|
-
# transaction fee row, but nothing says "None".
|
|
25
|
-
foreign_transaction_fee: null
|
|
26
|
+
foreign_transaction_fee: 0
|
|
26
27
|
welcome_offer:
|
|
27
28
|
dollars: 200
|
|
28
29
|
spend: 500
|
|
@@ -96,7 +97,6 @@ todo:
|
|
|
96
97
|
- 'Discover has published only 2026 on its 5% calendar (Oct-Dec 2026 is the last quarter shown). Add 2027 quarters when Discover posts them.'
|
|
97
98
|
- Discover's 5% activation isn't retroactive (5% applies "from 10/1/26 (or the date on which you activate 5%, whichever is later)"), and you can activate until the quarter's last day. The schema's activate_by can't say that purchases before activation earn 1%; it's set to the quarter's last day and the rule notes say so.
|
|
98
99
|
- The card page's and application terms' Cashback Match terms say "through your first 365 days"; the Cashback Bonus page says "your first 12 consecutive billing periods or 365 days, whichever is longer". Modeled as 365 days, the shorter. Cash back is matched only when processed within the period, so a purchase in its last days may post too late; earnrate goes by the purchase date.
|
|
99
|
-
- Foreign transaction fee isn't stated on any Discover page or in the application terms (their fee table has no foreign transaction row). Left null; confirm with the Cardmember Agreement or Capital One.
|
|
100
100
|
- Discover says 5% purchases "must be made with merchants in the U.S." The application terms put that sentence in the general earning paragraph, so it may also apply to the 1% base and Capital One Travel; only the rotating rules are marked us_only.
|
|
101
101
|
- Amusement parks, tourist attractions, aquariums, zoos, record stores, video rental, pool halls and bowling alleys earn 5% as Q4 2026 entertainment but have no earnrate category. Golf courses, country clubs and collegiate sporting events are excluded but can't be told apart from the entertainment category.
|
|
102
102
|
- Third-party payment accounts and digital wallet transactions may not earn 5% if they don't carry a qualifying MCC. Not modeled.
|
|
@@ -162,6 +162,9 @@ evidence:
|
|
|
162
162
|
- rule: annual_fee
|
|
163
163
|
url: https://disclosures.capitalone.com/disclosure.42300.en-US.html
|
|
164
164
|
quote: 'Annual Fee None.'
|
|
165
|
+
- rule: foreign_transaction_fee
|
|
166
|
+
url: https://www.discover.com/credit-cards/card-smarts/best-international-travel-credit-card/
|
|
167
|
+
quote: 'Discover® has no foreign transaction fee'
|
|
165
168
|
- rule: annual_fee
|
|
166
169
|
url: https://www.discover.com/credit-cards/cash-back/it-card/
|
|
167
170
|
quote: 'No, there is no annual fee for the Discover it Card.'
|
package/data/merchants/seed.yaml
CHANGED
|
@@ -748,7 +748,7 @@
|
|
|
748
748
|
|
|
749
749
|
# Transit
|
|
750
750
|
- { id: uber, name: Uber, category: transit.rideshare }
|
|
751
|
-
- { id: uber-one, name: Uber One, category: other }
|
|
751
|
+
- { id: uber-one, name: Uber One, category: other, preposition: on }
|
|
752
752
|
- { id: lyft, name: Lyft, category: transit.rideshare }
|
|
753
753
|
- { id: amtrak, name: Amtrak, domains: [amtrak.com], category: transit }
|
|
754
754
|
- {
|
|
@@ -3359,11 +3359,13 @@
|
|
|
3359
3359
|
name: Walmart+ membership
|
|
3360
3360
|
aliases: [walmart+, walmart+ membership fee]
|
|
3361
3361
|
category: other
|
|
3362
|
+
preposition: on
|
|
3362
3363
|
note: The Walmart+ membership fee, not shopping at Walmart.
|
|
3363
3364
|
- id: peloton-membership
|
|
3364
3365
|
name: Peloton membership
|
|
3365
3366
|
aliases: [peloton all-access, peloton app, peloton app one, peloton strength+]
|
|
3366
3367
|
category: other
|
|
3368
|
+
preposition: on
|
|
3367
3369
|
note: A Peloton membership or app subscription, not equipment.
|
|
3368
3370
|
- id: chatgpt
|
|
3369
3371
|
name: ChatGPT
|
package/dist/cli/wallet.js
CHANGED
|
@@ -192,7 +192,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
192
192
|
return RUNTIME_ERROR;
|
|
193
193
|
}
|
|
194
194
|
const header = result.exact ? '' : 'No card matches every word. Closest:\n';
|
|
195
|
-
io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
|
|
195
|
+
io.stdout(`${header}${result.cards.map((c) => formatCardInfo(catalog, c)).join('\n')}\n`);
|
|
196
196
|
return 0;
|
|
197
197
|
}
|
|
198
198
|
if (command === 'values') {
|
|
@@ -6,7 +6,7 @@ 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, } from "./text.js";
|
|
9
|
+
import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
|
|
10
10
|
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
11
11
|
// quick picks.
|
|
12
12
|
export const EVERYDAY_PURCHASES = [
|
|
@@ -82,7 +82,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
82
82
|
...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
|
|
83
83
|
];
|
|
84
84
|
return [
|
|
85
|
-
`Working on ${name}
|
|
85
|
+
`Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
86
86
|
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
87
87
|
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
88
88
|
];
|
|
@@ -130,7 +130,8 @@ function abroadRow(catalog, wallet, date) {
|
|
|
130
130
|
country: ABROAD,
|
|
131
131
|
}, merchant, { credits: false });
|
|
132
132
|
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
133
|
-
const
|
|
133
|
+
const knownFee = (id) => catalog.cards.find((c) => c.id === id)?.foreign_transaction_fee ?? 0;
|
|
134
|
+
const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => knownFee(r.cardId) > 0);
|
|
134
135
|
return {
|
|
135
136
|
label: ABROAD_LABEL,
|
|
136
137
|
cardId: top?.cardId ?? null,
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
3
3
|
import { milestonePays } from "./milestones.js";
|
|
4
4
|
import { optionName } from "./rule-text.js";
|
|
5
5
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
@@ -24,7 +24,7 @@ shortName) {
|
|
|
24
24
|
items.push({
|
|
25
25
|
kind: 'activate',
|
|
26
26
|
cardId: card.id,
|
|
27
|
-
text: `Activate ${name}
|
|
27
|
+
text: `Activate ${possessive(name)} bonus ${a.preposition} ${a.what}${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
|
}
|
|
@@ -41,7 +41,7 @@ shortName) {
|
|
|
41
41
|
items.push({
|
|
42
42
|
kind: 'pick',
|
|
43
43
|
cardId: card.id,
|
|
44
|
-
text: `Pick ${name}
|
|
44
|
+
text: `Pick ${possessive(name)} ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
|
|
45
45
|
due,
|
|
46
46
|
});
|
|
47
47
|
}
|
|
@@ -56,7 +56,7 @@ shortName) {
|
|
|
56
56
|
items.push({
|
|
57
57
|
kind: 'pick',
|
|
58
58
|
cardId: card.id,
|
|
59
|
-
text: `Pick ${name}
|
|
59
|
+
text: `Pick ${possessive(name)} categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
|
|
60
60
|
due: end,
|
|
61
61
|
});
|
|
62
62
|
}
|
|
@@ -66,7 +66,7 @@ shortName) {
|
|
|
66
66
|
items.push({
|
|
67
67
|
kind: 'pick',
|
|
68
68
|
cardId: card.id,
|
|
69
|
-
text: `${name}
|
|
69
|
+
text: `${possessive(name)} ${listed(assumed.map((c) => choiceName(c.id)))} pick is assumed to be ${listed(assumed.flatMap((c) => (c.default ?? []).map((o) => optionName(catalog, card, c.id, o))))}; set it if you picked something else.`,
|
|
70
70
|
due: null,
|
|
71
71
|
});
|
|
72
72
|
for (const cap of s.caps) {
|
|
@@ -78,7 +78,7 @@ shortName) {
|
|
|
78
78
|
kind: 'cap',
|
|
79
79
|
cardId: card.id,
|
|
80
80
|
text: cap.remaining === 0
|
|
81
|
-
? `${name}
|
|
81
|
+
? `${possessive(name)} ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
|
|
82
82
|
: `${name}: ${money(cap.remaining)} left of ${money(cap.amount)} ${CAP_PERIODS[cap.period]} at the bonus rate on ${cap.label}.`,
|
|
83
83
|
due: resets,
|
|
84
84
|
});
|
|
@@ -94,18 +94,21 @@ shortName) {
|
|
|
94
94
|
due: m.until,
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
+
const toEnroll = s.credits.filter((c) => !c.enrolled);
|
|
98
|
+
if (toEnroll.length > 0)
|
|
99
|
+
items.push({
|
|
100
|
+
kind: 'enroll',
|
|
101
|
+
cardId: card.id,
|
|
102
|
+
ref: toEnroll.map((c) => c.id).join(','),
|
|
103
|
+
text: enrollCreditText(name, toEnroll.map((c) => c.name)),
|
|
104
|
+
due: null,
|
|
105
|
+
});
|
|
97
106
|
for (const c of s.credits) {
|
|
98
107
|
if (!c.enrolled)
|
|
99
|
-
|
|
100
|
-
kind: 'enroll',
|
|
101
|
-
cardId: card.id,
|
|
102
|
-
ref: c.id,
|
|
103
|
-
text: enrollCreditText(name, c.name),
|
|
104
|
-
due: null,
|
|
105
|
-
});
|
|
108
|
+
continue;
|
|
106
109
|
// One that needs no enrolling shows once the cardholder uses it, so a
|
|
107
110
|
// card's every credit isn't a to-do for someone who never shops there.
|
|
108
|
-
|
|
111
|
+
if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
|
|
109
112
|
items.push({
|
|
110
113
|
kind: 'credit',
|
|
111
114
|
cardId: card.id,
|
|
@@ -120,7 +123,7 @@ shortName) {
|
|
|
120
123
|
items.push({
|
|
121
124
|
kind: 'bonus',
|
|
122
125
|
cardId: card.id,
|
|
123
|
-
text: `${name}
|
|
126
|
+
text: `${possessive(name)} welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
|
|
124
127
|
due: b.deadline,
|
|
125
128
|
});
|
|
126
129
|
}
|
|
@@ -139,7 +142,7 @@ shortName) {
|
|
|
139
142
|
items.push({
|
|
140
143
|
kind: 'rotation',
|
|
141
144
|
cardId: card.id,
|
|
142
|
-
text: `${name}
|
|
145
|
+
text: `${possessive(name)} ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
|
|
143
146
|
due: null,
|
|
144
147
|
});
|
|
145
148
|
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -4,7 +4,7 @@ import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
|
4
4
|
import { creditName } from "./rule-text.js";
|
|
5
5
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
6
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
7
|
-
import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
|
|
7
|
+
import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./recommend.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
9
|
import { membershipsUsed } from "./setup.js";
|
|
10
10
|
import { optionName } from "./rule-text.js";
|
|
@@ -131,7 +131,7 @@ function membershipUpgrades(catalog, card, held, merchant, purchase, options, cu
|
|
|
131
131
|
memberships: [...options.memberships, membership],
|
|
132
132
|
});
|
|
133
133
|
const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
|
|
134
|
-
const fee =
|
|
134
|
+
const fee = foreignFeeFor(card, purchase);
|
|
135
135
|
const pct = evaluateCard(card, held, ctx).best.rate *
|
|
136
136
|
cardValuation(catalog, wallet, card).centsPerUnit -
|
|
137
137
|
fee;
|
package/dist/engine/recommend.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
-
import { openedOn } from "./intro.js";
|
|
4
|
+
import { introWindows, openedOn, withIntro } from "./intro.js";
|
|
5
5
|
import { membershipName } from "./memberships.js";
|
|
6
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
7
7
|
import { daysBetween } from "./periods.js";
|
|
@@ -9,12 +9,18 @@ import { creditAt, creditDollars } from "./credits.js";
|
|
|
9
9
|
import { ruleWhat } from "./rule-text.js";
|
|
10
10
|
import { rotationGap } from "./rotation.js";
|
|
11
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
12
|
-
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
12
|
+
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, possessive, } from "./text.js";
|
|
13
13
|
import { cardValuation } from "./valuation.js";
|
|
14
14
|
export const STALE_AFTER_DAYS = 180;
|
|
15
15
|
// The engine only tells the U.S. from everywhere else.
|
|
16
16
|
export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
|
|
17
17
|
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
18
|
+
// A fee that isn't on file counts as the usual 3%, so a card can't win
|
|
19
|
+
// abroad on a guess that it charges none.
|
|
20
|
+
export const ASSUMED_FOREIGN_FEE = 3;
|
|
21
|
+
export const foreignFeeFor = (card, purchase) => isForeign(purchase)
|
|
22
|
+
? (card.foreign_transaction_fee ?? ASSUMED_FOREIGN_FEE)
|
|
23
|
+
: 0;
|
|
18
24
|
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
19
25
|
// leaves unassigned, and nothing prints it.
|
|
20
26
|
export const ABROAD = 'ZZ';
|
|
@@ -79,8 +85,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category, options = {}
|
|
|
79
85
|
const bonusPoints = milestonePoints(milestones);
|
|
80
86
|
const perDollar = evaluation.best.rate +
|
|
81
87
|
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
82
|
-
|
|
83
|
-
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
88
|
+
const fee = foreignFeeFor(card, purchase);
|
|
84
89
|
const fresh = card.intro_rates.length > 0 && openedOn(held) === null
|
|
85
90
|
? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
|
|
86
91
|
: null;
|
|
@@ -138,18 +143,19 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
138
143
|
const { best } = evaluation;
|
|
139
144
|
const lines = [];
|
|
140
145
|
const currency = valuation.currency;
|
|
146
|
+
// The standard rate first; a first-year rate on top of it is its own line.
|
|
147
|
+
// The intro's terms stay in search_cards, not in every answer.
|
|
148
|
+
const [intro] = best.intro;
|
|
149
|
+
const standard = earnText(catalog, intro ? best.standardRate : best.nominalRate, currency);
|
|
141
150
|
if (best.rule) {
|
|
142
151
|
const note = best.rule.note ? ` ${best.rule.note}` : '';
|
|
143
|
-
lines.push(`${
|
|
152
|
+
lines.push(`${standard} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
144
153
|
}
|
|
145
154
|
else {
|
|
146
|
-
lines.push(`${
|
|
147
|
-
}
|
|
148
|
-
const [intro] = best.intro;
|
|
149
|
-
if (intro) {
|
|
150
|
-
const note = intro.intro.note ? ` ${intro.intro.note}` : '';
|
|
151
|
-
lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
|
|
155
|
+
lines.push(`${standard}, its base rate. No bonus category applies here.`);
|
|
152
156
|
}
|
|
157
|
+
if (intro)
|
|
158
|
+
lines.push(introRateText(earnText(catalog, best.nominalRate, currency), standard, intro.intro.days, intro.until));
|
|
153
159
|
const cap = best.cap;
|
|
154
160
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
155
161
|
const over = amount - cap.remaining;
|
|
@@ -177,10 +183,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
177
183
|
const { card, valuation, evaluation } = entry;
|
|
178
184
|
const { best, nominal } = evaluation;
|
|
179
185
|
const warnings = [];
|
|
180
|
-
if (entry.fee > 0)
|
|
181
|
-
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
182
186
|
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
183
|
-
warnings.push(
|
|
187
|
+
warnings.push(`Its foreign transaction fee isn't on file, so earnrate assumes the usual ${pct(entry.fee)} and it nets ${pct(entry.rateReturnPct)}. Check the issuer's terms before using it abroad.`);
|
|
188
|
+
else if (entry.fee > 0)
|
|
189
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
|
|
184
190
|
const capLabel = (e) => e.cap
|
|
185
191
|
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
186
192
|
: '';
|
|
@@ -212,12 +218,14 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
212
218
|
if (evaluation.staleChoice !== null) {
|
|
213
219
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
214
220
|
}
|
|
221
|
+
const windows = introWindows(card, entry.held, purchase.date);
|
|
215
222
|
for (const rule of evaluation.needsActivation) {
|
|
223
|
+
const rate = earnText(catalog, withIntro(rule.rate, rule.id, windows), valuation.currency);
|
|
216
224
|
if (rule.activate_by !== undefined && rule.activate_by < purchase.date) {
|
|
217
|
-
warnings.push(`The bonus here (${
|
|
225
|
+
warnings.push(`The bonus here (${rate}) needed activating by ${shortDate(rule.activate_by)}.`);
|
|
218
226
|
continue;
|
|
219
227
|
}
|
|
220
|
-
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${
|
|
228
|
+
warnings.push(`Activate this bonus with the issuer (it's free)${rule.activate_by === undefined ? '' : ` by ${shortDate(rule.activate_by)}`} to earn ${rate} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
221
229
|
}
|
|
222
230
|
return warnings;
|
|
223
231
|
}
|
|
@@ -389,9 +397,12 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
389
397
|
const winner = entries[0];
|
|
390
398
|
const warnings = [];
|
|
391
399
|
const notes = [];
|
|
392
|
-
const charging = entries.filter((e) => e.fee > 0);
|
|
400
|
+
const charging = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee !== null);
|
|
401
|
+
const assumed = entries.filter((e) => e.fee > 0 && e.card.foreign_transaction_fee === null);
|
|
393
402
|
if (charging.length > 0 && entries.length > 1)
|
|
394
403
|
notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
|
|
404
|
+
if (assumed.length > 0 && entries.length > 1)
|
|
405
|
+
notes.push(`${listed(assumed.map(displayName))} ${assumed.length === 1 ? 'has' : 'have'} no foreign transaction fee on file, so the usual ${pct(ASSUMED_FOREIGN_FEE)} is subtracted.`);
|
|
395
406
|
if (merchant.source === 'none') {
|
|
396
407
|
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
|
|
397
408
|
}
|
|
@@ -453,7 +464,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
453
464
|
const gap = card ? rotationGap(card, purchase.date) : null;
|
|
454
465
|
if (card && gap !== null) {
|
|
455
466
|
const name = held.checkout_label ?? card.name;
|
|
456
|
-
warnings.push(`${name}
|
|
467
|
+
warnings.push(`${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
|
|
457
468
|
}
|
|
458
469
|
}
|
|
459
470
|
for (const id of merchant.bookedVia) {
|
|
@@ -516,7 +527,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
516
527
|
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
517
528
|
notes.unshift(ties
|
|
518
529
|
? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
|
|
519
|
-
: `You're behind pace on ${displayName(bonus.entry)}
|
|
530
|
+
: `You're behind pace on ${possessive(displayName(bonus.entry))} welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
520
531
|
? ''
|
|
521
532
|
: costHere === null
|
|
522
533
|
? ` It earns ${pct(top.effectiveReturnPct)} here against ${pct(first.effectiveReturnPct)} on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
package/dist/engine/record.js
CHANGED
package/dist/engine/rotation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { periodKey } from "./periods.js";
|
|
2
|
+
import { possessive } from "./text.js";
|
|
2
3
|
// The period of the card's rotating categories that `date` falls in, when no
|
|
3
4
|
// rule in the data covers it yet; null when it's covered or the card doesn't
|
|
4
5
|
// rotate.
|
|
@@ -16,4 +17,4 @@ export function rotationGap(card, date) {
|
|
|
16
17
|
}
|
|
17
18
|
// What answers say about a gap: "Freedom Flex's rotating categories for
|
|
18
19
|
// 2027-Q2 aren't in earnrate's data yet. ..."
|
|
19
|
-
export const rotationGapText = (name, gap) => `${name}
|
|
20
|
+
export const rotationGapText = (name, gap) => `${possessive(name)} rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -3,13 +3,12 @@ import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, lis
|
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
6
|
-
const BOOKING = /^(Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
6
|
+
const BOOKING = /^(Activity|Attraction|Car rental|Cruise|Flight|Hotel|Vacation rental)\b/;
|
|
7
|
+
const plural = (kind) => kind === 'Activity' ? 'activities' : `${kind.toLowerCase()}s`;
|
|
7
8
|
function merchantName(catalog, id) {
|
|
8
9
|
const merchant = findMerchant(catalog, id);
|
|
9
10
|
const name = merchant?.name ?? words(id);
|
|
10
|
-
return merchant?.kind === 'booking'
|
|
11
|
-
? name.replace(BOOKING, (kind) => `${kind.toLowerCase()}s`)
|
|
12
|
-
: name;
|
|
11
|
+
return merchant?.kind === 'booking' ? name.replace(BOOKING, plural) : name;
|
|
13
12
|
}
|
|
14
13
|
export function optionName(catalog, card, choiceId, option) {
|
|
15
14
|
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
@@ -76,9 +75,27 @@ export function introWhat(catalog, card, intro) {
|
|
|
76
75
|
return `${change} for the ${introPeriod(intro.days)}`;
|
|
77
76
|
}
|
|
78
77
|
// What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
]
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
// Bookings through a travel site the credit also names go without saying:
|
|
79
|
+
// "Capital One Travel", not each kind of booking through it.
|
|
80
|
+
export function creditName(catalog, credit) {
|
|
81
|
+
const ids = credit.match.merchants ?? [];
|
|
82
|
+
const portals = ids
|
|
83
|
+
.map((m) => findMerchant(catalog, m))
|
|
84
|
+
.filter((m) => m?.kind === 'portal')
|
|
85
|
+
.map((m) => ` through ${m?.name ?? ''}`);
|
|
86
|
+
const merchants = ids.filter((m) => {
|
|
87
|
+
const merchant = findMerchant(catalog, m);
|
|
88
|
+
return !(merchant?.kind === 'booking' &&
|
|
89
|
+
portals.some((p) => merchant.name.endsWith(p)));
|
|
90
|
+
});
|
|
91
|
+
return listed([
|
|
92
|
+
...merchants.map((m) => merchantName(catalog, m)),
|
|
93
|
+
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
// "at Lululemon", "at restaurants", "on travel", "on Walmart+ membership":
|
|
97
|
+
// the category's or merchant's own preposition.
|
|
98
|
+
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ||
|
|
99
|
+
(credit.match.merchants ?? []).some((m) => findMerchant(catalog, m)?.preposition === 'on')
|
|
100
|
+
? 'on'
|
|
101
|
+
: 'at'} ${creditName(catalog, credit)}`;
|
package/dist/engine/setup.js
CHANGED
|
@@ -4,7 +4,7 @@ import { milestoneWhat } from "./milestones.js";
|
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
6
|
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
-
import { creditBack, ENROLL_FIRST, idList, listed } from "./text.js";
|
|
7
|
+
import { creditBack, ENROLL_FIRST, idList, listed, possessive } from "./text.js";
|
|
8
8
|
// Rules that start within this many days are worth activating now.
|
|
9
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
10
10
|
const searchWords = (s) => s
|
|
@@ -168,7 +168,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
168
168
|
notes.push({
|
|
169
169
|
kind: 'pick',
|
|
170
170
|
text: choice.default && picked.has(choice.id)
|
|
171
|
-
? `${name}
|
|
171
|
+
? `${possessive(name)} ${choice.id} pick is assumed to be ${idList(choice.default)}, the default. Set it if a different one was picked (${String(choice.pick)} of: ${options}).`
|
|
172
172
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
173
173
|
});
|
|
174
174
|
}
|
|
@@ -177,7 +177,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
177
177
|
if (missing.length > 0) {
|
|
178
178
|
notes.push({
|
|
179
179
|
kind: 'membership',
|
|
180
|
-
text: `Some of ${name}
|
|
180
|
+
text: `Some of ${possessive(name)} rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
|
|
181
181
|
});
|
|
182
182
|
}
|
|
183
183
|
const toActivate = activationRules(card, today).filter((id) => !isActivated(card, held, id));
|
package/dist/engine/simulate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { milestoneWhat } from "./milestones.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, paymentNote, roundCents, wholeDollars } from "./text.js";
|
|
9
|
+
import { money, paymentNote, roundCents, wholeDollars, possessive, } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
12
|
const MONTHS = 12;
|
|
@@ -164,13 +164,13 @@ held = []) {
|
|
|
164
164
|
...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
|
|
165
165
|
];
|
|
166
166
|
if (gaps.length > 0)
|
|
167
|
-
notes.push(`${card.name}
|
|
167
|
+
notes.push(`${possessive(card.name)} rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
168
168
|
if (card.annual_fee === undefined)
|
|
169
|
-
notes.push(`${card.name}
|
|
169
|
+
notes.push(`${possessive(card.name)} annual fee isn't on file, so none is subtracted.`);
|
|
170
170
|
else if (!held.includes(card.id) &&
|
|
171
171
|
card.first_year_fee !== undefined &&
|
|
172
172
|
card.first_year_fee !== card.annual_fee)
|
|
173
|
-
notes.push(`${card.name}
|
|
173
|
+
notes.push(`${possessive(card.name)} fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
174
174
|
}
|
|
175
175
|
return {
|
|
176
176
|
cards: wallet.cards.map((h) => ({
|
package/dist/engine/text.js
CHANGED
|
@@ -129,6 +129,8 @@ export function paymentNote(cards) {
|
|
|
129
129
|
}
|
|
130
130
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
131
131
|
// list's: those lists use commas only, with those names last.
|
|
132
|
+
// "Amex Platinum's", "Capital One Savor Rewards'".
|
|
133
|
+
export const possessive = (name) => name.endsWith('s') ? `${name}'` : `${name}'s`;
|
|
132
134
|
export const listed = (parts, joiner = 'and') => {
|
|
133
135
|
const anded = parts.filter((p) => p.includes(' and '));
|
|
134
136
|
if (anded.length > 0)
|
|
@@ -187,7 +189,7 @@ export const introSince = (days) => days === 365 ? 'in the last year' : `in the
|
|
|
187
189
|
export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
|
|
188
190
|
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
189
191
|
export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
|
|
190
|
-
export const introEndsText = (name, days, until) => `${name}
|
|
192
|
+
export const introEndsText = (name, days, until) => `${possessive(name)} ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
191
193
|
// Merchant statement credits. Always dollars, never a percentage.
|
|
192
194
|
export const CREDIT_PERIODS = {
|
|
193
195
|
month: 'each month',
|
|
@@ -243,6 +245,8 @@ export function creditShort(c, dollars) {
|
|
|
243
245
|
// For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
|
|
244
246
|
// 31, 2026: $35 left."
|
|
245
247
|
export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
|
|
246
|
-
export const enrollCreditText = (card,
|
|
248
|
+
export const enrollCreditText = (card, names) => names.length === 1
|
|
249
|
+
? `Enroll in ${possessive(card)} ${names[0] ?? ''} credit with the issuer, then mark it enrolled.`
|
|
250
|
+
: `Enroll in ${possessive(card)} ${listed(names)} credits with the issuer, then mark them enrolled.`;
|
|
247
251
|
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
248
252
|
export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
|
package/dist/format/cards.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { membershipName } from "../engine/memberships.js";
|
|
1
2
|
import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
|
-
export function formatCardInfo(card) {
|
|
3
|
+
export function formatCardInfo(catalog, card) {
|
|
3
4
|
const fee = card.annualFee === null
|
|
4
5
|
? ''
|
|
5
6
|
: card.annualFee === 0
|
|
@@ -13,7 +14,9 @@ export function formatCardInfo(card) {
|
|
|
13
14
|
]
|
|
14
15
|
: []),
|
|
15
16
|
...(card.memberships.length > 0
|
|
16
|
-
? [
|
|
17
|
+
? [
|
|
18
|
+
`rates that need a membership: ${card.memberships.map((id) => `${membershipName(catalog, id)} (membership id ${id})`).join(', ')}`,
|
|
19
|
+
]
|
|
17
20
|
: []),
|
|
18
21
|
...(card.activation.length > 0
|
|
19
22
|
? [`activate with the issuer: ${card.activation.join(', ')}`]
|
|
@@ -21,6 +24,7 @@ export function formatCardInfo(card) {
|
|
|
21
24
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
25
|
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
23
26
|
...card.introRates.map((i) => `new accounts: ${i.text}; needs the opening date`),
|
|
27
|
+
...card.credits.map((c) => `merchant credit ${c.id}: ${c.text}`),
|
|
24
28
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
25
29
|
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
26
30
|
];
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { Document, parse, visit } from 'yaml';
|
|
2
2
|
import { walletProblems } from "../data/integrity.js";
|
|
3
3
|
import { CARD_NUMBER_LABEL, CARD_NUMBER_REDEMPTION, } from "../schema/card-number.js";
|
|
4
|
-
import { WALLET_VERSION, WalletSchema } from "../schema/wallet.js";
|
|
4
|
+
import { WALLET_VERSION, WalletCardSchema, WalletSchema, } from "../schema/wallet.js";
|
|
5
5
|
// A wallet as the YAML file the CLI reads, for moving it between the
|
|
6
6
|
// website, the remote server and the command line. Node-free.
|
|
7
|
+
// Every field the schema defines, in its order, so a new wallet field is
|
|
8
|
+
// exported without anyone remembering to add it here.
|
|
9
|
+
const CARD_FIELDS = Object.keys(WalletCardSchema.shape);
|
|
10
|
+
const isEmpty = (value) => value === undefined ||
|
|
11
|
+
(Array.isArray(value)
|
|
12
|
+
? value.length === 0
|
|
13
|
+
: typeof value === 'object' &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
Object.keys(value).length === 0);
|
|
7
16
|
function compact(wallet) {
|
|
8
17
|
return {
|
|
9
18
|
version: wallet.version ?? WALLET_VERSION,
|
|
@@ -13,20 +22,7 @@ function compact(wallet) {
|
|
|
13
22
|
...(wallet.memberships.length > 0
|
|
14
23
|
? { memberships: wallet.memberships }
|
|
15
24
|
: {}),
|
|
16
|
-
cards: wallet.cards.map((c) => (
|
|
17
|
-
id: c.id,
|
|
18
|
-
...(c.checkout_label === undefined
|
|
19
|
-
? {}
|
|
20
|
-
: { checkout_label: c.checkout_label }),
|
|
21
|
-
...(c.anniversary === undefined ? {} : { anniversary: c.anniversary }),
|
|
22
|
-
...(c.opened === undefined ? {} : { opened: c.opened }),
|
|
23
|
-
...(Object.keys(c.cap_usage).length > 0
|
|
24
|
-
? { cap_usage: c.cap_usage }
|
|
25
|
-
: {}),
|
|
26
|
-
...(c.chosen === undefined ? {} : { chosen: c.chosen }),
|
|
27
|
-
...(c.activated.length > 0 ? { activated: c.activated } : {}),
|
|
28
|
-
...(c.bonus === undefined ? {} : { bonus: c.bonus }),
|
|
29
|
-
})),
|
|
25
|
+
cards: wallet.cards.map((c) => Object.fromEntries(CARD_FIELDS.flatMap((k) => (isEmpty(c[k]) ? [] : [[k, c[k]]])))),
|
|
30
26
|
};
|
|
31
27
|
}
|
|
32
28
|
// Dates, periods and anniversaries are quoted: YAML 1.1 parsers read a bare 2026-10-15 as a
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, } from "../engine/text.js";
|
|
1
|
+
import { CAP_PERIODS, introAdjective, introSince, money, NETWORK_NAMES, periodPhrase, roundCents, shortDate, possessive, } from "../engine/text.js";
|
|
2
2
|
// "To do now", above the card details; nothing when all's done.
|
|
3
3
|
export function formatChecklist(items) {
|
|
4
4
|
return items.length === 0
|
|
@@ -64,29 +64,33 @@ export function formatWalletSummary(summary) {
|
|
|
64
64
|
: "Point values: earnrate's defaults.";
|
|
65
65
|
return [...cards, memberships, values].join('\n');
|
|
66
66
|
}
|
|
67
|
-
export function formatBooking(amount,
|
|
67
|
+
export function formatBooking(amount, name, // the card's short name
|
|
68
|
+
cap, bonus, milestones = [], credit = null) {
|
|
68
69
|
const messages = [];
|
|
69
70
|
if (credit)
|
|
70
|
-
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${
|
|
71
|
+
messages.push(`Used ${money(roundCents(credit.after - credit.before))} of ${possessive(name)} ${credit.name} credit: ${money(credit.after)} of ${money(credit.amount)} ${periodPhrase(credit.periodKind, credit.period)}, ${money(roundCents(Math.max(0, credit.amount - credit.after)))} left.`);
|
|
71
72
|
if (cap) {
|
|
72
73
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
73
|
-
messages.push(`Recorded ${money(amount)} on ${
|
|
74
|
+
messages.push(`Recorded ${money(amount)} on ${name} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
|
|
74
75
|
}
|
|
75
76
|
if (bonus) {
|
|
76
77
|
const left = Math.max(0, bonus.spend - bonus.after);
|
|
77
78
|
messages.push(left === 0
|
|
78
|
-
? `Welcome bonus spend met on ${
|
|
79
|
-
: `Welcome bonus on ${
|
|
79
|
+
? `Welcome bonus spend met on ${name}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
80
|
+
: `Welcome bonus on ${name}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
80
81
|
}
|
|
81
82
|
// Every milestone counts the same purchases, so one line covers them.
|
|
82
83
|
const first = milestones[0];
|
|
83
84
|
if (first) {
|
|
84
85
|
const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
|
|
85
86
|
const next = milestones.find((m) => m.after < m.spend);
|
|
87
|
+
const period = first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year';
|
|
86
88
|
messages.push([
|
|
87
|
-
|
|
89
|
+
first.started
|
|
90
|
+
? `Started tracking spend on ${name} this ${period} with this purchase: ${money(first.after)}. Spending on it earlier this ${period} isn't counted; give the total so far (milestoneSpent) to include it.`
|
|
91
|
+
: `Spend on ${name} this ${period}: ${money(first.after)}.`,
|
|
88
92
|
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
89
|
-
...(next
|
|
93
|
+
...(next && !first.started
|
|
90
94
|
? [
|
|
91
95
|
`${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
|
|
92
96
|
]
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -417,7 +417,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
|
|
|
417
417
|
content: [
|
|
418
418
|
{
|
|
419
419
|
type: 'text',
|
|
420
|
-
text: header +
|
|
420
|
+
text: header +
|
|
421
|
+
result.cards.map((c) => formatCardInfo(catalog, c)).join('\n'),
|
|
421
422
|
},
|
|
422
423
|
],
|
|
423
424
|
structuredContent: result,
|
package/dist/mcp/tools/wallet.js
CHANGED
|
@@ -149,7 +149,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
149
149
|
bonus,
|
|
150
150
|
milestones,
|
|
151
151
|
credit,
|
|
152
|
-
message: `${formatBooking(amount, cardId, booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
|
|
152
|
+
message: `${formatBooking(amount, cardText(catalog).shortName(cardId), booking, bonus, milestones, credit)} Saved in ${ctx.walletName}.`,
|
|
153
153
|
}
|
|
154
154
|
: {
|
|
155
155
|
recorded: false,
|
|
@@ -157,7 +157,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
|
|
|
157
157
|
bonus: null,
|
|
158
158
|
milestones: [],
|
|
159
159
|
credit: null,
|
|
160
|
-
message: `Nothing to track: ${cardId} has no spending cap, open welcome bonus, spend milestone or enrolled merchant credit for this purchase, so the wallet wasn't changed.`,
|
|
160
|
+
message: `Nothing to track: ${cardText(catalog).shortName(cardId)} has no spending cap, open welcome bonus, spend milestone or enrolled merchant credit for this purchase, so the wallet wasn't changed.`,
|
|
161
161
|
};
|
|
162
162
|
return {
|
|
163
163
|
content: [
|
package/dist/schema/merchant.js
CHANGED
|
@@ -34,6 +34,9 @@ export const MerchantSchema = z.strictObject({
|
|
|
34
34
|
// Costco Travel), or a booking made through one (booking: "Hotel through
|
|
35
35
|
// Amex Travel").
|
|
36
36
|
kind: z.enum(['portal', 'booking']).optional(),
|
|
37
|
+
// "on Walmart+ membership" rather than "at": a membership or subscription,
|
|
38
|
+
// not a store.
|
|
39
|
+
preposition: z.literal('on').optional(),
|
|
37
40
|
note: z.string().optional(),
|
|
38
41
|
});
|
|
39
42
|
export const MerchantsFileSchema = z.array(MerchantSchema);
|