earnrate 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/data/CHANGELOG.md +28 -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/chase-ihg-one-rewards-business.yaml +1 -1
- package/data/cards/discover-it-cash-back.yaml +9 -6
- package/data/cards/venmo-credit-card.yaml +1 -1
- package/data/categories.yaml +10 -0
- package/data/merchants/seed.yaml +3 -1
- package/dist/cli/import.js +14 -11
- package/dist/cli/todo.js +3 -2
- package/dist/cli/wallet.js +50 -17
- package/dist/engine/cheat-sheet.js +15 -11
- package/dist/engine/checklist.js +23 -25
- package/dist/engine/credits.js +2 -2
- package/dist/engine/evaluate.js +1 -1
- package/dist/engine/explore.js +4 -4
- package/dist/engine/intro.js +1 -5
- package/dist/engine/memberships.js +2 -6
- package/dist/engine/offers.js +3 -5
- package/dist/engine/periods.js +5 -0
- package/dist/engine/profiles.js +2 -1
- package/dist/engine/recommend.js +46 -33
- package/dist/engine/record.js +2 -1
- package/dist/engine/rotation.js +2 -1
- package/dist/engine/rule-text.js +98 -33
- package/dist/engine/setup.js +7 -3
- package/dist/engine/simulate.js +4 -4
- package/dist/engine/text.js +29 -8
- package/dist/engine/wallet-change.js +5 -5
- package/dist/engine/wallet-summary.js +24 -21
- package/dist/format/card-text.js +2 -3
- package/dist/format/cards.js +7 -3
- package/dist/format/explore.js +2 -2
- package/dist/format/wallet-yaml.js +13 -17
- package/dist/format/wallet.js +14 -10
- package/dist/import/analyze.js +13 -1
- package/dist/mcp/context.js +2 -3
- package/dist/mcp/inputs.js +3 -3
- package/dist/mcp/schemas.js +2 -0
- package/dist/mcp/server.js +0 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +10 -5
- package/dist/mcp/tools/wallet.js +10 -6
- package/dist/schema/card.js +6 -10
- package/dist/schema/merchant.js +3 -0
- package/package.json +2 -1
package/dist/cli/wallet.js
CHANGED
|
@@ -19,12 +19,18 @@ const CARD_OPTIONS = `Options:
|
|
|
19
19
|
card is ranked at its standard rates.
|
|
20
20
|
--activated <rules> Rules you've activated with the issuer, comma
|
|
21
21
|
separated. Replaces the current list.
|
|
22
|
-
--enrolled <credits>
|
|
22
|
+
--enrolled <credits> Statement credits you've enrolled in with the
|
|
23
23
|
issuer, comma separated, e.g. --enrolled lululemon.
|
|
24
24
|
Replaces the current list.
|
|
25
25
|
--credit-used <credit>=<dollars>
|
|
26
26
|
How much of a credit you've received this period,
|
|
27
27
|
e.g. --credit-used lululemon=40. Repeat for each.
|
|
28
|
+
--cap-spent <cap>=<dollars>
|
|
29
|
+
Spending toward a cap so far this period, e.g.
|
|
30
|
+
--cap-spent us-supermarkets=1200. Repeat for each.
|
|
31
|
+
--milestone-spent <bonus>=<dollars>
|
|
32
|
+
Spending this year toward a spend bonus, e.g.
|
|
33
|
+
--milestone-spent spend-15k=9000. Repeat for each.
|
|
28
34
|
--bonus-opened <date> Track a welcome bonus: the day the account opened
|
|
29
35
|
(YYYY-MM-DD). Spending needed and the deadline
|
|
30
36
|
default to the card's published offer.
|
|
@@ -53,15 +59,17 @@ ${CARD_OPTIONS}
|
|
|
53
59
|
update: `Usage: ${CLI_NAME} update <card-id> [options]
|
|
54
60
|
|
|
55
61
|
Changes a card in your wallet: picks for this period, label, anniversary,
|
|
56
|
-
opening date, activations,
|
|
57
|
-
current period are merged with ones
|
|
62
|
+
opening date, activations, spending toward caps and spend bonuses, statement
|
|
63
|
+
credits or welcome bonus. Picks for the current period are merged with ones
|
|
64
|
+
already set.
|
|
58
65
|
|
|
59
66
|
${CARD_OPTIONS}
|
|
60
67
|
`,
|
|
61
68
|
remove: `Usage: ${CLI_NAME} remove <card-id>
|
|
62
69
|
|
|
63
|
-
Removes a card from your wallet, with its tracked cap usage
|
|
64
|
-
progress
|
|
70
|
+
Removes a card from your wallet, with its tracked cap usage, welcome-bonus
|
|
71
|
+
progress, spend-bonus spending and statement credits used. Your memberships
|
|
72
|
+
and point values stay.
|
|
65
73
|
`,
|
|
66
74
|
memberships: `Usage: ${CLI_NAME} memberships [<membership>...] [--none]
|
|
67
75
|
|
|
@@ -143,6 +151,8 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
143
151
|
activated: { type: 'string' },
|
|
144
152
|
enrolled: { type: 'string' },
|
|
145
153
|
'credit-used': { type: 'string', multiple: true },
|
|
154
|
+
'cap-spent': { type: 'string', multiple: true },
|
|
155
|
+
'milestone-spent': { type: 'string', multiple: true },
|
|
146
156
|
'bonus-opened': { type: 'string' },
|
|
147
157
|
'bonus-spent': { type: 'string' },
|
|
148
158
|
'bonus-spend': { type: 'string' },
|
|
@@ -173,7 +183,14 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
173
183
|
values.opened !== undefined ||
|
|
174
184
|
values.activated !== undefined ||
|
|
175
185
|
values.enrolled !== undefined ||
|
|
176
|
-
values['credit-used'] !== undefined
|
|
186
|
+
values['credit-used'] !== undefined ||
|
|
187
|
+
values['cap-spent'] !== undefined ||
|
|
188
|
+
values['milestone-spent'] !== undefined ||
|
|
189
|
+
values['bonus-opened'] !== undefined ||
|
|
190
|
+
values['bonus-spent'] !== undefined ||
|
|
191
|
+
values['bonus-spend'] !== undefined ||
|
|
192
|
+
values['bonus-by'] !== undefined ||
|
|
193
|
+
values['no-bonus'] !== undefined;
|
|
177
194
|
if (command !== 'add' && command !== 'update' && cardOptionsGiven) {
|
|
178
195
|
return usage(`${CLI_NAME} ${command} doesn't take card options.`);
|
|
179
196
|
}
|
|
@@ -192,7 +209,7 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
192
209
|
return RUNTIME_ERROR;
|
|
193
210
|
}
|
|
194
211
|
const header = result.exact ? '' : 'No card matches every word. Closest:\n';
|
|
195
|
-
io.stdout(`${header}${result.cards.map(formatCardInfo).join('\n')}\n`);
|
|
212
|
+
io.stdout(`${header}${result.cards.map((c) => formatCardInfo(catalog, c)).join('\n')}\n`);
|
|
196
213
|
return 0;
|
|
197
214
|
}
|
|
198
215
|
if (command === 'values') {
|
|
@@ -277,17 +294,33 @@ export async function runWalletCommand(command, argv, io) {
|
|
|
277
294
|
.map((r) => r.trim())
|
|
278
295
|
.filter((r) => r !== '');
|
|
279
296
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
297
|
+
// --credit-used, --cap-spent and --milestone-spent: <id>=<dollars>, repeated.
|
|
298
|
+
const dollarPairs = (flag, example) => {
|
|
299
|
+
const pairs = values[flag];
|
|
300
|
+
if (pairs === undefined)
|
|
301
|
+
return undefined;
|
|
302
|
+
const out = {};
|
|
303
|
+
for (const pair of pairs) {
|
|
304
|
+
const [id = '', dollars] = pair.split('=');
|
|
305
|
+
const n = dollarsFlag(flag, dollars);
|
|
306
|
+
if (id.trim() === '' || n === undefined || typeof n === 'string')
|
|
307
|
+
return `--${flag} takes <id>=<dollars>, like ${example}.`;
|
|
308
|
+
out[id.trim()] = n;
|
|
288
309
|
}
|
|
289
|
-
|
|
290
|
-
}
|
|
310
|
+
return out;
|
|
311
|
+
};
|
|
312
|
+
const creditUsed = dollarPairs('credit-used', 'lululemon=40');
|
|
313
|
+
const capSpent = dollarPairs('cap-spent', 'us-supermarkets=1200');
|
|
314
|
+
const milestoneSpent = dollarPairs('milestone-spent', 'spend-15k=9000');
|
|
315
|
+
for (const result of [creditUsed, capSpent, milestoneSpent])
|
|
316
|
+
if (typeof result === 'string')
|
|
317
|
+
return usage(result);
|
|
318
|
+
if (typeof creditUsed === 'object')
|
|
319
|
+
input.creditUsed = creditUsed;
|
|
320
|
+
if (typeof capSpent === 'object')
|
|
321
|
+
input.capSpent = capSpent;
|
|
322
|
+
if (typeof milestoneSpent === 'object')
|
|
323
|
+
input.milestoneSpent = milestoneSpent;
|
|
291
324
|
if (values['no-bonus']) {
|
|
292
325
|
input.bonus = null;
|
|
293
326
|
}
|
|
@@ -6,7 +6,8 @@ import { periodKey } from "./periods.js";
|
|
|
6
6
|
import { ABROAD, recommend } from "./recommend.js";
|
|
7
7
|
import { checklist } from "./checklist.js";
|
|
8
8
|
import { setupNotes } from "./setup.js";
|
|
9
|
-
import { capitalize, CREDITS_LEFT_OUT, feeText, listed, money, paymentNote, shortDate, } from "./text.js";
|
|
9
|
+
import { capitalize, CREDITS_LEFT_OUT, feeText, heldName, listed, money, paymentNote, shortDate, possessive, } from "./text.js";
|
|
10
|
+
const midSentence = (item) => item.phrase ?? ('merchant' in item ? item.label : item.label.toLowerCase());
|
|
10
11
|
// The everyday kinds of purchase: the cheat sheet's rows and the site's
|
|
11
12
|
// quick picks.
|
|
12
13
|
export const EVERYDAY_PURCHASES = [
|
|
@@ -16,7 +17,11 @@ export const EVERYDAY_PURCHASES = [
|
|
|
16
17
|
{ label: 'Amazon', merchant: 'amazon' },
|
|
17
18
|
{ label: 'Costco', merchant: 'costco' },
|
|
18
19
|
{ label: 'Travel', category: 'travel' },
|
|
19
|
-
{
|
|
20
|
+
{
|
|
21
|
+
label: 'Streaming (Netflix)',
|
|
22
|
+
merchant: 'netflix',
|
|
23
|
+
phrase: 'streaming (Netflix)',
|
|
24
|
+
},
|
|
20
25
|
{ label: 'Drugstores', category: 'drugstore' },
|
|
21
26
|
{ label: 'Everything else', category: 'other' },
|
|
22
27
|
];
|
|
@@ -43,7 +48,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
43
48
|
// The card that earns the most, even when a welcome bonus would take the
|
|
44
49
|
// pick for one purchase: the sheet is for the whole quarter.
|
|
45
50
|
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
46
|
-
ranked.push({
|
|
51
|
+
ranked.push({ phrase: midSentence(item), cards: rec.ranked });
|
|
47
52
|
return {
|
|
48
53
|
label: item.label,
|
|
49
54
|
cardId: top?.cardId ?? null,
|
|
@@ -58,22 +63,20 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
58
63
|
const status = bonusStatus(catalog, wallet, held, date);
|
|
59
64
|
if (status?.state !== 'open')
|
|
60
65
|
return [];
|
|
61
|
-
const name = held
|
|
62
|
-
catalog.cards.find((c) => c.id === held.id)?.name ??
|
|
63
|
-
held.id;
|
|
66
|
+
const name = heldName(catalog, held);
|
|
64
67
|
// Rows where the bonus card matches the row's card, or is close to it
|
|
65
68
|
// (as single answers mention it).
|
|
66
69
|
const matches = [];
|
|
67
70
|
const near = [];
|
|
68
|
-
for (const {
|
|
71
|
+
for (const { phrase, cards } of ranked) {
|
|
69
72
|
const best = cards[0]?.effectiveReturnPct;
|
|
70
73
|
const own = cards.find((c) => c.cardId === held.id)?.effectiveReturnPct;
|
|
71
74
|
if (best === undefined || own === undefined)
|
|
72
75
|
continue;
|
|
73
76
|
if (best - own < EPSILON)
|
|
74
|
-
matches.push(
|
|
77
|
+
matches.push(phrase);
|
|
75
78
|
else if (isClose(own, best))
|
|
76
|
-
near.push(
|
|
79
|
+
near.push(phrase);
|
|
77
80
|
}
|
|
78
81
|
const where = [
|
|
79
82
|
...(matches.length > 0
|
|
@@ -82,7 +85,7 @@ export function cheatSheet(catalog, wallet, date, items = EVERYDAY_PURCHASES) {
|
|
|
82
85
|
...(near.length > 0 ? [`comes within a point on ${listed(near)}`] : []),
|
|
83
86
|
];
|
|
84
87
|
return [
|
|
85
|
-
`Working on ${name}
|
|
88
|
+
`Working on ${possessive(name)} welcome bonus: ${money(status.remaining ?? 0)} more by ${shortDate(status.deadline ?? date)}. ${where.length > 0
|
|
86
89
|
? `It ${where.join(' and ')}, so those are the places to use it.`
|
|
87
90
|
: 'It trails the listed cards everywhere, so use it on everyday spending only as needed to reach the bonus.'}`,
|
|
88
91
|
];
|
|
@@ -130,7 +133,8 @@ function abroadRow(catalog, wallet, date) {
|
|
|
130
133
|
country: ABROAD,
|
|
131
134
|
}, merchant, { credits: false });
|
|
132
135
|
const top = rec.decision ? rec.ranked[0] : undefined;
|
|
133
|
-
const
|
|
136
|
+
const knownFee = (id) => catalog.cards.find((c) => c.id === id)?.foreign_transaction_fee ?? 0;
|
|
137
|
+
const allCharge = rec.ranked.length > 0 && rec.ranked.every((r) => knownFee(r.cardId) > 0);
|
|
134
138
|
return {
|
|
135
139
|
label: ABROAD_LABEL,
|
|
136
140
|
cardId: top?.cardId ?? null,
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, } from "./text.js";
|
|
1
|
+
import { addDays, currentPeriodEnd, daysBetween } from "./periods.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
|
}
|
|
@@ -148,8 +151,8 @@ shortName) {
|
|
|
148
151
|
a.cardId.localeCompare(b.cardId));
|
|
149
152
|
}
|
|
150
153
|
// An all-day calendar event for each dated action (activations, picks,
|
|
151
|
-
// welcome-bonus deadlines, credits to use), with a reminder two days before.
|
|
152
|
-
// device: nothing is sent anywhere.
|
|
154
|
+
// welcome-bonus deadlines, credits to use), with a reminder two days before.
|
|
155
|
+
// Built on the device: nothing is sent anywhere.
|
|
153
156
|
export function checklistCalendar(items, stamp) {
|
|
154
157
|
const escape = (t) => t.replace(/\\/g, '\\\\').replace(/[,;]/g, (c) => `\\${c}`);
|
|
155
158
|
const fold = (line) => {
|
|
@@ -159,11 +162,6 @@ export function checklistCalendar(items, stamp) {
|
|
|
159
162
|
return parts.join('\r\n ');
|
|
160
163
|
};
|
|
161
164
|
const day = (d) => d.replace(/-/g, '');
|
|
162
|
-
const next = (d) => {
|
|
163
|
-
const t = new Date(`${d}T00:00:00Z`);
|
|
164
|
-
t.setUTCDate(t.getUTCDate() + 1);
|
|
165
|
-
return t.toISOString().slice(0, 10);
|
|
166
|
-
};
|
|
167
165
|
const events = items
|
|
168
166
|
.filter((i) => i.due !== null && i.kind !== 'cap' && i.kind !== 'rotation')
|
|
169
167
|
.flatMap((i) => {
|
|
@@ -173,7 +171,7 @@ export function checklistCalendar(items, stamp) {
|
|
|
173
171
|
`UID:${i.kind}-${i.cardId}${i.ref === undefined ? '' : `-${i.ref}`}-${due}@earnrate.dev`,
|
|
174
172
|
`DTSTAMP:${stamp}`,
|
|
175
173
|
`DTSTART;VALUE=DATE:${day(due)}`,
|
|
176
|
-
`DTEND;VALUE=DATE:${day(
|
|
174
|
+
`DTEND;VALUE=DATE:${day(addDays(due, 1))}`,
|
|
177
175
|
fold(`SUMMARY:${escape(i.text)}`),
|
|
178
176
|
'BEGIN:VALARM',
|
|
179
177
|
'TRIGGER:-P2D',
|
package/dist/engine/credits.js
CHANGED
|
@@ -15,8 +15,8 @@ export function creditCovers(catalog, credit, merchant) {
|
|
|
15
15
|
return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
|
|
16
16
|
}
|
|
17
17
|
// A credit that needs enrolling counts only once the wallet says it's done.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
|
|
19
|
+
function creditState(catalog, credit, held, date) {
|
|
20
20
|
const anniversary = held.anniversary ?? null;
|
|
21
21
|
const period = periodKey(credit.period, date, anniversary);
|
|
22
22
|
const usage = held.credit_usage?.[credit.id];
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { EPSILON } from "./compare.js";
|
|
|
3
3
|
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
4
4
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
5
|
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
|
-
|
|
6
|
+
const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
7
7
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
8
8
|
const payment = purchase.payment ?? null;
|
|
9
9
|
return {
|
package/dist/engine/explore.js
CHANGED
|
@@ -4,11 +4,11 @@ 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";
|
|
11
|
-
import {
|
|
11
|
+
import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, capSentence, } from "./text.js";
|
|
12
12
|
import { cardValuation } from "./valuation.js";
|
|
13
13
|
import { EPSILON } from "./compare.js";
|
|
14
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -117,7 +117,7 @@ function conditionsText(catalog, card, r) {
|
|
|
117
117
|
if (r.membership !== null)
|
|
118
118
|
conditions.push(`With your ${membershipName(catalog, r.membership)}.`);
|
|
119
119
|
if (r.cap) {
|
|
120
|
-
conditions.push(
|
|
120
|
+
conditions.push(`${capSentence(r.cap, earnText(catalog, r.cap.thenRate, r.cap.currency))}.`);
|
|
121
121
|
}
|
|
122
122
|
return conditions;
|
|
123
123
|
}
|
|
@@ -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/intro.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const t = new Date(`${date}T00:00:00Z`);
|
|
3
|
-
t.setUTCDate(t.getUTCDate() + days);
|
|
4
|
-
return t.toISOString().slice(0, 10);
|
|
5
|
-
}
|
|
1
|
+
import { addDays } from "./periods.js";
|
|
6
2
|
// Never assumed: a card without a date is ranked at its standard rates.
|
|
7
3
|
export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
8
4
|
// The opening day is day 1, so 365 days run through the day before the first
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import { money, words } from "./text.js";
|
|
1
|
+
import { listed, money, withArticle, words } from "./text.js";
|
|
2
2
|
export function membership(catalog, id) {
|
|
3
3
|
return catalog.memberships.find((m) => m.id === id);
|
|
4
4
|
}
|
|
5
5
|
export function membershipName(catalog, id) {
|
|
6
6
|
return membership(catalog, id)?.name ?? words(id);
|
|
7
7
|
}
|
|
8
|
-
// "a Costco membership", for a sentence.
|
|
9
|
-
function withArticle(name) {
|
|
10
|
-
return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`;
|
|
11
|
-
}
|
|
12
8
|
// Tiers of one membership: holding one, the others in its group aren't
|
|
13
9
|
// missing (BofA Preferred Rewards).
|
|
14
10
|
export function sameGroup(catalog, a, b) {
|
|
@@ -67,5 +63,5 @@ export function membershipNeeded(catalog, card, paid) {
|
|
|
67
63
|
}
|
|
68
64
|
export function membershipNote(catalog, m) {
|
|
69
65
|
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
70
|
-
return `${names
|
|
66
|
+
return `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}, ${money(m.annualFee)} a year, counted here like an annual fee. If you're already a member, add it to your memberships.`;
|
|
71
67
|
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -2,17 +2,15 @@ import { bonusDollars } from "./bonus.js";
|
|
|
2
2
|
import { matchesFilters } from "./explore.js";
|
|
3
3
|
import { membershipCosts, paidMemberships } from "./memberships.js";
|
|
4
4
|
import { inEffect } from "./periods.js";
|
|
5
|
-
import { CREDITS_LEFT_OUT, money, roundCents } from "./text.js";
|
|
5
|
+
import { CREDITS_LEFT_OUT, heldName, listed, money, roundCents, } from "./text.js";
|
|
6
6
|
import { cardValuation } from "./valuation.js";
|
|
7
7
|
function ineligibleText(catalog, wallet, notWith) {
|
|
8
8
|
const held = wallet.cards
|
|
9
9
|
.filter((c) => notWith.includes(c.id))
|
|
10
|
-
.map((c) => c
|
|
11
|
-
catalog.cards.find((k) => k.id === c.id)?.name ??
|
|
12
|
-
c.id);
|
|
10
|
+
.map((c) => heldName(catalog, c));
|
|
13
11
|
return held.length === 0
|
|
14
12
|
? null
|
|
15
|
-
: `Likely not eligible: you hold ${held
|
|
13
|
+
: `Likely not eligible: you hold ${listed(held)}, and the issuer's terms rule out its cardholders.`;
|
|
16
14
|
}
|
|
17
15
|
export const OFFERS_MAY_DIFFER = 'Yours may differ: issuers test and target offers, so use the one shown to you when you apply.';
|
|
18
16
|
// "Up to 60,000 Chase Ultimate Rewards after $4,000 in purchases within 3
|
package/dist/engine/periods.js
CHANGED
|
@@ -23,6 +23,11 @@ export function periodKey(period, date, anniversary) {
|
|
|
23
23
|
export function daysBetween(from, to) {
|
|
24
24
|
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000);
|
|
25
25
|
}
|
|
26
|
+
export function addDays(date, days) {
|
|
27
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
28
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
29
|
+
return t.toISOString().slice(0, 10);
|
|
30
|
+
}
|
|
26
31
|
// The user's local calendar date, not UTC: a purchase at 9pm in Los Angeles
|
|
27
32
|
// belongs to that day.
|
|
28
33
|
export function localToday(now = new Date()) {
|
package/dist/engine/profiles.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { listed } from "./text.js";
|
|
1
2
|
export const VALUATION_PROFILES = {
|
|
2
3
|
flat: { MR: 1.0, UR: 1.0 },
|
|
3
4
|
'points-heavy': { MR: 1.7, UR: 1.7 },
|
|
@@ -7,7 +8,7 @@ export function profileText(profile) {
|
|
|
7
8
|
const values = Object.entries(VALUATION_PROFILES[profile]);
|
|
8
9
|
const cents = [...new Set(values.map(([, v]) => v))];
|
|
9
10
|
return cents.length === 1
|
|
10
|
-
? `${values.map(([id]) => id)
|
|
11
|
+
? `${listed(values.map(([id]) => id))} at ${String(cents[0])}¢`
|
|
11
12
|
: values.map(([id, v]) => `${id} at ${String(v)}¢`).join(', ');
|
|
12
13
|
}
|
|
13
14
|
export function isValuationProfile(name) {
|