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 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.04.15
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-03
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: 3000
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 plus a $250 statement credit, both after the same $3,000 in purchases; 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 to receive a welcome offer if you have or have had this Card, 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.'
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 + $250 Statement Credit'
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 $3,000 in purchases on your new Card within the first 6 months'
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: 'Offer ends 11/04/2026 at 11:59pm MST.'
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 this Card, the Delta SkyMiles® Platinum American Express Card'
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-03
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: 4000
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 plus a $300 statement credit, both after the same $4,000 in purchases; 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 to receive a welcome offer if you have or have had this Card, the Delta SkyMiles® Reserve American Express Card or previous versions of these Cards." Amex may also decline based on your card history.'
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 + Earn $300 Statement Credit'
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 $4,000 in purchases on your new Card within the first 6 months'
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: 'Offer ends 11/04/2026 at 23:59 MST.'
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 this Card, the Delta SkyMiles® Reserve American Express Card'
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-03
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: 50000
18
- spend: 10000
17
+ points: 125000
18
+ spend: 5000
19
19
  months: 6
20
- valid_to: 2026-11-04
21
- note: 'Plus two Delta Comfort Flight Certificates after the same $10,000 in purchases: each is one round trip from the U.S. to the lower 48, Puerto Rico or the U.S. Virgin Islands, booked at least 21 days ahead, with up to $80 in taxes and fees, flown by January 15, 2028. Amex: "You may not be eligible to receive 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.'
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: 'Earn 50,000 Bonus Miles + 2 Delta Comfort Flight Certificates'
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 $10,000 or more (the "Threshold Amount") in eligible purchases'
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/en-us/credit-cards/apply/terms/personal/delta-skymiles-reserve-american-express-card/25330-10-0#offer-terms
92
- quote: 'Offer ends 11/04/2026 at 23:59 MST.'
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-04
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: 100000
19
- spend: 8000
18
+ points: 75000
19
+ spend: 6000
20
20
  months: 6
21
- valid_to: 2026-11-04
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 100,000 Marriott Bonvoy® Bonus Points and 1 Free Night Award'
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: 'after you spend $8,000 or more (the “Threshold Amount”) in eligible purchases'
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: 'Offer ends 11/4/2026 at 11:59 PM MST.'
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-03
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
- # No page states it: the application terms' fee table lists no foreign
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.'
@@ -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
@@ -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}'s welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
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 allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => r.foreignFee !== null);
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,
@@ -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}'s bonus ${a.preposition} ${a.what}${a.activateBy === null ? '' : ` by ${shortDate(a.activateBy)}`}${starts}.`,
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}'s ${listed(unpicked.map((c) => choiceName(c.id)))} categories for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
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}'s categories for next ${ending.period.replace('_', ' ')}; this one ends ${shortDate(end)}.`,
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}'s ${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.`,
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}'s ${cap.label} cap is used up${resets === null ? '' : ` until ${shortDate(resets)}`}; another card may earn more there.`
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
- items.push({
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
- else if (c.left !== null && c.left > 0 && (c.enroll || c.tracked))
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}'s welcome bonus: ${money(b.remaining)} to go by ${shortDate(b.deadline)}, about ${wholeDollars(b.remaining / weeks)} a week.`,
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}'s ${s.rotationGap} categories aren't out yet; earnrate adds them once the issuer posts them.`,
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
  }
@@ -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 = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
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;
@@ -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
- // An unknown fee isn't guessed: it's warned about instead.
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(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
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(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
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("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
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 (${earnText(catalog, rule.rate, valuation.currency)}) needed activating by ${shortDate(rule.activate_by)}.`);
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 ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
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}'s 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.`);
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)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
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.`
@@ -96,6 +96,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
96
96
  spend: p.milestone.spend,
97
97
  before: p.spent,
98
98
  after: roundCents(Math.max(0, p.spent + purchase.amount)),
99
+ started: !p.tracked,
99
100
  },
100
101
  ]);
101
102
  }
@@ -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}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
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.`;
@@ -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
- export const creditName = (catalog, credit) => listed([
80
- ...(credit.match.merchants ?? []).map((m) => merchantName(catalog, m)),
81
- ...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
82
- ]);
83
- // "at Lululemon", "at restaurants", "on travel": a category's own preposition.
84
- export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ? 'on' : 'at'} ${creditName(catalog, credit)}`;
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)}`;
@@ -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}'s ${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}).`
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}'s rates need ${listed(missing.map((m) => `${membershipName(catalog, m)} (${m})`), 'or')}; they're counted without it.`,
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));
@@ -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}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
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}'s annual fee isn't on file, so none is subtracted.`);
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}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
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) => ({
@@ -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}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
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, name) => `Enroll in ${card}'s ${name} credit with the issuer, then mark it enrolled.`;
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.';
@@ -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
- ? [`rates that need a membership: ${card.memberships.join(', ')}`]
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
@@ -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, cardId, cap, bonus, milestones = [], credit = null) {
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 ${cardId}'s ${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
+ 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 ${cardId} against "${cap.capKey}": ${money(cap.before)} → ${money(cap.after)} of ${money(cap.capAmount)} ${periodPhrase(cap.periodKind, cap.period)}, ${money(left)} left.`);
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 ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
79
- : `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
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
- `Spend on ${cardId} this ${first.periodKind === 'cardmember_year' ? 'cardmember year' : 'year'}: ${money(first.after)}.`,
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
  ]
@@ -245,6 +245,7 @@ export const RecordResultSchema = z.object({
245
245
  spend: z.number(),
246
246
  before: z.number(),
247
247
  after: z.number(),
248
+ started: z.boolean(),
248
249
  })),
249
250
  credit: z
250
251
  .object({
@@ -417,7 +417,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
417
417
  content: [
418
418
  {
419
419
  type: 'text',
420
- text: header + result.cards.map(formatCardInfo).join('\n'),
420
+ text: header +
421
+ result.cards.map((c) => formatCardInfo(catalog, c)).join('\n'),
421
422
  },
422
423
  ],
423
424
  structuredContent: result,
@@ -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: [
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.13",
3
+ "version": "0.2.14",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",