earnrate 0.2.16 → 0.2.18
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 +22 -10
- package/data/CHANGELOG.md +33 -1
- package/data/VERSION +1 -1
- package/data/cards/amex-business-gold.yaml +1 -0
- package/data/cards/amex-business-platinum.yaml +15 -1
- package/data/cards/amex-platinum.yaml +1 -1
- package/data/cards/barclays-jetblue-plus.yaml +19 -1
- package/data/cards/chase-freedom-flex.yaml +9 -12
- package/data/cards/chase-ihg-one-rewards-business.yaml +19 -3
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +5 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +7 -1
- package/data/cards/chase-ihg-one-rewards.yaml +13 -2
- package/data/cards/chase-ink-business-cash.yaml +5 -1
- package/data/cards/chase-ink-business-unlimited.yaml +5 -1
- package/data/cards/chase-prime-visa.yaml +3 -3
- package/data/cards/chase-sapphire-reserve.yaml +12 -3
- package/data/cards/chase-united-business.yaml +8 -1
- package/data/cards/chase-united-club-business.yaml +10 -1
- package/data/cards/chase-united-club.yaml +5 -1
- package/data/cards/chase-united-explorer.yaml +5 -1
- package/data/cards/chase-united-quest.yaml +5 -1
- package/data/cards/citi-aadvantage-business.yaml +5 -0
- package/data/cards/citi-aadvantage-executive.yaml +2 -0
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
- package/data/cards/citi-custom-cash.yaml +8 -10
- package/data/cards/citi-double-cash.yaml +1 -0
- package/data/cards/citi-my-best-buy-visa.yaml +1 -0
- package/data/cards/citi-strata-elite.yaml +2 -0
- package/data/cards/citi-strata-premier.yaml +16 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +16 -1
- package/data/changes.yaml +2840 -0
- package/data/merchants/overrides.yaml +3 -3
- package/dist/cli/args.js +13 -7
- package/dist/cli/offers.js +21 -5
- package/dist/cli/run.js +18 -3
- package/dist/cli/wallet.js +46 -11
- package/dist/data/change-integrity.js +105 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +20 -0
- package/dist/data/load.js +5 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +21 -5
- package/dist/engine/changes.js +124 -0
- package/dist/engine/checklist.js +42 -7
- package/dist/engine/credits.js +56 -7
- package/dist/engine/evaluate.js +21 -1
- package/dist/engine/explore.js +52 -15
- package/dist/engine/memberships.js +21 -9
- package/dist/engine/milestones.js +11 -3
- package/dist/engine/offers.js +60 -6
- package/dist/engine/periods.js +7 -0
- package/dist/engine/recommend.js +21 -2
- package/dist/engine/renewal.js +101 -0
- package/dist/engine/rule-text.js +21 -13
- package/dist/engine/setup.js +12 -8
- package/dist/engine/simulate.js +57 -17
- package/dist/engine/text.js +110 -5
- package/dist/engine/wallet-change.js +34 -15
- package/dist/engine/wallet-summary.js +29 -10
- package/dist/format/card-text.js +10 -11
- package/dist/format/explore.js +12 -8
- package/dist/format/offers.js +5 -3
- package/dist/format/wallet-yaml.js +3 -0
- package/dist/format/wallet.js +8 -1
- package/dist/mcp/context.js +11 -3
- package/dist/mcp/inputs.js +12 -2
- package/dist/mcp/prompts.js +3 -3
- package/dist/mcp/schemas.js +47 -0
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +42 -24
- package/dist/mcp/tools/wallet.js +33 -9
- package/dist/schema/card.js +18 -0
- package/dist/schema/changes.js +73 -0
- package/dist/schema/wallet.js +11 -3
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { offerTerms } from "./offers.js";
|
|
2
|
+
import { creditName, ruleWhat } from "./rule-text.js";
|
|
3
|
+
import { creditBack, currencyName, earnText, money, pct, possessive, shortCardName, shortDate, } from "./text.js";
|
|
4
|
+
// Newest first.
|
|
5
|
+
export function datedChanges(releases) {
|
|
6
|
+
return releases
|
|
7
|
+
.flatMap((r) => r.changes.map((change) => ({
|
|
8
|
+
version: r.version,
|
|
9
|
+
date: change.date ?? r.date,
|
|
10
|
+
change,
|
|
11
|
+
})))
|
|
12
|
+
.sort((a, b) => (a.date === b.date ? 0 : a.date < b.date ? 1 : -1));
|
|
13
|
+
}
|
|
14
|
+
export const issuerChanges = (releases) => datedChanges(releases).filter((d) => d.change.cause === 'issuer');
|
|
15
|
+
export function subjectOf(catalog, kind, id) {
|
|
16
|
+
switch (kind) {
|
|
17
|
+
case 'cards':
|
|
18
|
+
return catalog.cards.find((c) => c.id === id);
|
|
19
|
+
case 'merchants':
|
|
20
|
+
return [...catalog.merchants.seed, ...catalog.merchants.overrides].find((m) => m.id === id);
|
|
21
|
+
case 'categories':
|
|
22
|
+
return catalog.categories.find((c) => c.id === id);
|
|
23
|
+
case 'currencies':
|
|
24
|
+
return catalog.currencies.find((c) => c.id === id);
|
|
25
|
+
case 'memberships':
|
|
26
|
+
return catalog.memberships.find((m) => m.id === id);
|
|
27
|
+
case 'issuers':
|
|
28
|
+
return catalog.issuers.find((i) => i.id === id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
32
|
+
// `rules.amazon.rate` on a card: list items are found by their id.
|
|
33
|
+
export function fieldValue(subject, field) {
|
|
34
|
+
if (field === undefined)
|
|
35
|
+
return subject;
|
|
36
|
+
let value = subject;
|
|
37
|
+
for (const step of field.split('.')) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
value = value.find((item) => isRecord(item) && item.id === step);
|
|
40
|
+
else if (isRecord(value))
|
|
41
|
+
value = value[step];
|
|
42
|
+
else
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
const NO_SPEND = ' with no spending required';
|
|
48
|
+
// Mid-sentence: "up to 80,000 points".
|
|
49
|
+
const lower = (text) => text.replace(/^Up to /, 'up to ');
|
|
50
|
+
// Only the part that changed is said twice: "$150 → $200 with no spending
|
|
51
|
+
// required", "80,000 points after $3,000 … → after $2,000 …".
|
|
52
|
+
function offerChange(catalog, card, was, now) {
|
|
53
|
+
const units = currencyName(catalog, card.currency);
|
|
54
|
+
const terms = (o) => lower(offerTerms(o, units));
|
|
55
|
+
const amount = (o) => lower(offerTerms({ ...o, spend: 0, months: 1 }, units)).slice(0, -NO_SPEND.length);
|
|
56
|
+
const tail = (o) => terms(o).slice(amount(o).length);
|
|
57
|
+
if (tail(was) === tail(now))
|
|
58
|
+
return `${amount(was)} → ${terms(now)}`;
|
|
59
|
+
if (amount(was) === amount(now))
|
|
60
|
+
return `${terms(was)} → ${tail(now).trim()}`;
|
|
61
|
+
return `${terms(was)} → ${terms(now)}`;
|
|
62
|
+
}
|
|
63
|
+
const feeWords = (fee) => typeof fee === 'number' ? (fee === 0 ? 'none' : money(fee)) : 'not on file';
|
|
64
|
+
// "Prime Visa's welcome offer: $150 → $200 with no spending required": one
|
|
65
|
+
// plain line for an issuer change, from its old and new values.
|
|
66
|
+
export function changeHeadline(catalog, change) {
|
|
67
|
+
const id = change.cards?.[0] ?? '';
|
|
68
|
+
const card = catalog.cards.find((c) => c.id === id);
|
|
69
|
+
if (!card || change.field === undefined)
|
|
70
|
+
throw new Error(`no headline for a change to ${id}`);
|
|
71
|
+
const name = possessive(card.short_name ?? shortCardName(card.name));
|
|
72
|
+
const [kind, item] = change.field.split('.');
|
|
73
|
+
const { was, now } = change;
|
|
74
|
+
switch (kind) {
|
|
75
|
+
case 'welcome_offer':
|
|
76
|
+
if (was === null)
|
|
77
|
+
return `${name} new welcome offer: ${terms(now)}`;
|
|
78
|
+
if (now === null)
|
|
79
|
+
return `${name} welcome offer ended: ${terms(was)}`;
|
|
80
|
+
return `${name} welcome offer: ${offerChange(catalog, card, was, now)}`;
|
|
81
|
+
case 'annual_fee':
|
|
82
|
+
case 'first_year_fee':
|
|
83
|
+
return `${name} ${kind === 'annual_fee' ? 'annual fee' : 'first-year fee'}: ${feeWords(was)} → ${feeWords(now)}`;
|
|
84
|
+
case 'foreign_transaction_fee': {
|
|
85
|
+
const fee = (v) => typeof v === 'number' ? (v === 0 ? 'none' : pct(v)) : 'not on file';
|
|
86
|
+
return `${name} foreign transaction fee: ${fee(was)} → ${fee(now)}`;
|
|
87
|
+
}
|
|
88
|
+
case 'applications_closed':
|
|
89
|
+
return `${name} applications closed ${shortDate(String(now))}`;
|
|
90
|
+
case 'rules': {
|
|
91
|
+
const rule = (card.rules.find((r) => r.id === item) ?? now ?? was);
|
|
92
|
+
const rate = (v) => v === null
|
|
93
|
+
? 'none'
|
|
94
|
+
: earnText(catalog, typeof v === 'number' ? v : v.rate, card.currency);
|
|
95
|
+
return `${name} rate ${ruleWhat(catalog, rule, card)}: ${rate(was)} → ${rate(now)}`;
|
|
96
|
+
}
|
|
97
|
+
case 'credits': {
|
|
98
|
+
const credit = (card.credits.find((c) => c.id === item) ??
|
|
99
|
+
now ??
|
|
100
|
+
was);
|
|
101
|
+
const back = (v) => v === null ? 'none' : creditBack(v);
|
|
102
|
+
return `${name} ${creditName(catalog, credit)} credit: ${back(was)} → ${back(now)}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`no headline for an issuer change to ${change.field}`);
|
|
106
|
+
function terms(o) {
|
|
107
|
+
return lower(offerTerms(o, currencyName(catalog, card?.currency ?? '')));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function freshness(catalog, releases) {
|
|
111
|
+
const verified = catalog.cards.map((c) => c.last_verified).sort();
|
|
112
|
+
const first = releases.at(-1);
|
|
113
|
+
const latest = releases[0];
|
|
114
|
+
if (!first || !latest)
|
|
115
|
+
throw new Error('no data releases');
|
|
116
|
+
return {
|
|
117
|
+
issuerChanges: issuerChanges(releases).length,
|
|
118
|
+
since: first.date,
|
|
119
|
+
latestRelease: latest.date,
|
|
120
|
+
latestVersion: latest.version,
|
|
121
|
+
reviewedFrom: verified[0] ?? '',
|
|
122
|
+
reviewedTo: verified.at(-1) ?? '',
|
|
123
|
+
};
|
|
124
|
+
}
|
package/dist/engine/checklist.js
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
import { addDays, currentPeriodEnd, daysBetween } from "./periods.js";
|
|
2
|
-
import { CAP_PERIODS, choiceName, enrollCreditText, introEndsText, listed, money, shortDate, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
2
|
+
import { CAP_PERIODS, choiceName, askCreditText, enrollCreditText, introEndsText, listed, money, shortDate, renewalText, useCreditText, wholeDollars, possessive, } from "./text.js";
|
|
3
3
|
import { milestonePays } from "./milestones.js";
|
|
4
|
-
import {
|
|
4
|
+
import { renewalCheck, renewalDue } from "./renewal.js";
|
|
5
|
+
import { creditLabel, optionName } from "./rule-text.js";
|
|
5
6
|
import { activationsDue, walletSummary, } from "./wallet-summary.js";
|
|
7
|
+
// Node-free: earnrate.dev builds the same list from its browser wallet.
|
|
8
|
+
const issuerLabel = (catalog, id) => catalog.issuers.find((i) => i.id === id)?.name ?? id;
|
|
9
|
+
// "5% and 2% categories", "top category": a choice already named for its
|
|
10
|
+
// category isn't followed by "categories" again.
|
|
11
|
+
function pickWhat(names) {
|
|
12
|
+
const [only] = names;
|
|
13
|
+
if (names.length === 1 && only !== undefined && / category$/.test(only))
|
|
14
|
+
return only;
|
|
15
|
+
return `${listed(names)} categories`;
|
|
16
|
+
}
|
|
6
17
|
// What the wallet's cards need this period, soonest first: bonuses to
|
|
7
18
|
// activate, categories to pick, caps running low, welcome bonuses to finish,
|
|
8
19
|
// merchant credits to enroll in or use, and rotating categories the issuer
|
|
9
20
|
// hasn't posted yet.
|
|
10
21
|
export function checklist(catalog, wallet, today, summary = walletSummary(catalog, wallet, today),
|
|
11
22
|
// A shorter card name than the issuer's full one, where the caller has it.
|
|
12
|
-
shortName
|
|
23
|
+
shortName,
|
|
24
|
+
// For the renewal check; typical amounts when not given.
|
|
25
|
+
spending) {
|
|
13
26
|
const items = [];
|
|
14
27
|
for (const held of wallet.cards) {
|
|
15
28
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
@@ -41,7 +54,7 @@ shortName) {
|
|
|
41
54
|
items.push({
|
|
42
55
|
kind: 'pick',
|
|
43
56
|
cardId: card.id,
|
|
44
|
-
text: `Pick ${possessive(name)} ${
|
|
57
|
+
text: `Pick ${possessive(name)} ${pickWhat(unpicked.map((c) => choiceName(c.id)))} for this ${first.period.replace('_', ' ')}; until then they earn the base rate.`,
|
|
45
58
|
due,
|
|
46
59
|
});
|
|
47
60
|
}
|
|
@@ -66,7 +79,7 @@ shortName) {
|
|
|
66
79
|
items.push({
|
|
67
80
|
kind: 'pick',
|
|
68
81
|
cardId: card.id,
|
|
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.`,
|
|
82
|
+
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 ${assumed.every((c) => c.automatic) ? 'you expect another to have the most spending' : 'you picked something else'}.`,
|
|
70
83
|
due: null,
|
|
71
84
|
});
|
|
72
85
|
for (const cap of s.caps) {
|
|
@@ -94,7 +107,19 @@ shortName) {
|
|
|
94
107
|
due: m.until,
|
|
95
108
|
});
|
|
96
109
|
}
|
|
97
|
-
const
|
|
110
|
+
const toAsk = s.credits.filter((c) => !c.enroll && !c.confirmed && !c.skipped);
|
|
111
|
+
if (toAsk.length > 0)
|
|
112
|
+
items.push({
|
|
113
|
+
kind: 'enroll',
|
|
114
|
+
cardId: card.id,
|
|
115
|
+
ref: toAsk.map((c) => c.id).join(','),
|
|
116
|
+
text: askCreditText(name, toAsk.map((c) => {
|
|
117
|
+
const credit = card.credits.find((x) => x.id === c.id);
|
|
118
|
+
return credit ? creditLabel(catalog, credit) : c.name;
|
|
119
|
+
})),
|
|
120
|
+
due: null,
|
|
121
|
+
});
|
|
122
|
+
const toEnroll = s.credits.filter((c) => !c.enrolled && !c.skipped);
|
|
98
123
|
if (toEnroll.length > 0)
|
|
99
124
|
items.push({
|
|
100
125
|
kind: 'enroll',
|
|
@@ -104,7 +129,7 @@ shortName) {
|
|
|
104
129
|
due: null,
|
|
105
130
|
});
|
|
106
131
|
for (const c of s.credits) {
|
|
107
|
-
if (!c.enrolled)
|
|
132
|
+
if (!c.enrolled || c.skipped)
|
|
108
133
|
continue;
|
|
109
134
|
// One that needs no enrolling shows once the cardholder uses it, so a
|
|
110
135
|
// card's every credit isn't a to-do for someone who never shops there.
|
|
@@ -138,6 +163,16 @@ shortName) {
|
|
|
138
163
|
text: introEndsText(name, s.intro.days, s.intro.until),
|
|
139
164
|
due: s.intro.until,
|
|
140
165
|
});
|
|
166
|
+
const due = (card.annual_fee ?? 0) > 0 ? renewalDue(held, today) : null;
|
|
167
|
+
if (due !== null) {
|
|
168
|
+
const r = renewalCheck(catalog, wallet, card, due, spending);
|
|
169
|
+
items.push({
|
|
170
|
+
kind: 'renewal',
|
|
171
|
+
cardId: card.id,
|
|
172
|
+
text: renewalText(name, issuerLabel(catalog, card.issuer), r),
|
|
173
|
+
due,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
141
176
|
if (s.rotationGap !== null) {
|
|
142
177
|
items.push({
|
|
143
178
|
kind: 'rotation',
|
package/dist/engine/credits.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
2
|
import { categoryMatches } from "./evaluate.js";
|
|
3
|
-
import { periodEnd, periodKey } from "./periods.js";
|
|
4
|
-
import { creditName } from "./rule-text.js";
|
|
3
|
+
import { inEffect, periodEnd, periodKey } from "./periods.js";
|
|
4
|
+
import { creditLabel, creditName } from "./rule-text.js";
|
|
5
5
|
import { roundCents } from "./text.js";
|
|
6
6
|
// Only a store itself has a credit: a search for a kind of purchase
|
|
7
7
|
// ("clothing") never does.
|
|
@@ -16,6 +16,15 @@ export function creditCovers(catalog, credit, merchant) {
|
|
|
16
16
|
}
|
|
17
17
|
// A credit that needs enrolling counts only once the wallet says it's done.
|
|
18
18
|
const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
|
|
19
|
+
// The cardholder said they don't use it: it counts nowhere, enrolled or not.
|
|
20
|
+
export const isSkipped = (credit, held) => (held.skipped_credits ?? []).includes(credit.id);
|
|
21
|
+
// The cardholder said they use it: it's in `enrolled` (for one that needs
|
|
22
|
+
// no enrolling, that means "using it"), or one that needs no enrolling has
|
|
23
|
+
// already paid out. Until then a credit doesn't offset the annual fee.
|
|
24
|
+
export const creditConfirmed = (credit, held) => (held.enrolled ?? []).includes(credit.id) ||
|
|
25
|
+
(credit.enroll !== true && held.credit_usage?.[credit.id] !== undefined);
|
|
26
|
+
// A credit that counts against the fee: confirmed and not skipped.
|
|
27
|
+
export const creditInUse = (credit, held) => creditConfirmed(credit, held) && !isSkipped(credit, held);
|
|
19
28
|
function creditState(catalog, credit, held, date) {
|
|
20
29
|
const anniversary = held.anniversary ?? null;
|
|
21
30
|
const period = periodKey(credit.period, date, anniversary);
|
|
@@ -30,18 +39,23 @@ function creditState(catalog, credit, held, date) {
|
|
|
30
39
|
amount: credit.amount,
|
|
31
40
|
period: credit.period,
|
|
32
41
|
enrolled: isEnrolled(credit, held),
|
|
42
|
+
skipped: isSkipped(credit, held),
|
|
43
|
+
confirmed: creditConfirmed(credit, held),
|
|
33
44
|
used,
|
|
45
|
+
minAmount: credit.min_amount ?? null,
|
|
34
46
|
left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
|
|
35
47
|
until: period === null ? null : periodEnd(credit.period, period, anniversary),
|
|
36
48
|
};
|
|
37
49
|
}
|
|
38
|
-
|
|
39
|
-
export const
|
|
50
|
+
// The card's credits the issuer still offers on `date`.
|
|
51
|
+
export const liveCredits = (card, date) => card.credits.filter((c) => inEffect(c, date));
|
|
52
|
+
export const creditStates = (catalog, card, held, date) => liveCredits(card, date).map((c) => creditState(catalog, c, held, date));
|
|
53
|
+
export const counts = (s) => s.enrolled && !s.skipped && s.left !== null && s.left > 0;
|
|
40
54
|
// The card's credit at this merchant, if any: one that counts first, then
|
|
41
|
-
// the one with the most left.
|
|
55
|
+
// the one with the most left. A skipped credit isn't mentioned at all.
|
|
42
56
|
export function creditAt(catalog, card, held, merchant, date) {
|
|
43
|
-
const here = card
|
|
44
|
-
.filter((c) => creditCovers(catalog, c, merchant))
|
|
57
|
+
const here = liveCredits(card, date)
|
|
58
|
+
.filter((c) => !isSkipped(c, held) && creditCovers(catalog, c, merchant))
|
|
45
59
|
.map((c) => creditState(catalog, c, held, date))
|
|
46
60
|
.sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
|
|
47
61
|
return here[0] ?? null;
|
|
@@ -53,6 +67,8 @@ export function creditDollars(s, amount) {
|
|
|
53
67
|
return null;
|
|
54
68
|
if (!counts(s) || amount <= 0)
|
|
55
69
|
return 0;
|
|
70
|
+
if (s.credit.min_amount !== undefined && amount < s.credit.min_amount)
|
|
71
|
+
return 0;
|
|
56
72
|
return roundCents(Math.min(amount, s.left ?? 0));
|
|
57
73
|
}
|
|
58
74
|
const PER_YEAR = {
|
|
@@ -62,4 +78,37 @@ const PER_YEAR = {
|
|
|
62
78
|
calendar_year: 1,
|
|
63
79
|
cardmember_year: 1,
|
|
64
80
|
};
|
|
81
|
+
const monthIndex = (date) => Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7)) - 1;
|
|
82
|
+
// How many times the credit pays in the 12 months from `date`, counting the
|
|
83
|
+
// period `date` is in: every period's, or for a credit that ends, those that
|
|
84
|
+
// start by its last day.
|
|
85
|
+
function payoutsAYear(credit, date) {
|
|
86
|
+
const all = PER_YEAR[credit.period];
|
|
87
|
+
if (credit.valid_to === undefined)
|
|
88
|
+
return all;
|
|
89
|
+
if (!inEffect(credit, date))
|
|
90
|
+
return 0;
|
|
91
|
+
if (all === 1)
|
|
92
|
+
return 1;
|
|
93
|
+
const step = 12 / all;
|
|
94
|
+
const first = monthIndex(date) - (monthIndex(date) % step);
|
|
95
|
+
const starts = Math.floor((monthIndex(credit.valid_to) - first) / step) + 1;
|
|
96
|
+
return Math.min(all, starts);
|
|
97
|
+
}
|
|
98
|
+
// Dollars a year at face value, the most first: for ordering cards by credit.
|
|
65
99
|
export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
|
|
100
|
+
function creditsYear(catalog, card, credits, date) {
|
|
101
|
+
const live = credits.filter((c) => inEffect(c, date));
|
|
102
|
+
return {
|
|
103
|
+
cardId: card.id,
|
|
104
|
+
yearly: roundCents(live.reduce((sum, c) => sum + c.amount * payoutsAYear(c, date), 0)),
|
|
105
|
+
creditIds: live.map((c) => c.id),
|
|
106
|
+
names: live.map((c) => creditLabel(catalog, c)),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// Every credit on the card, as if each were used in full: for a card the
|
|
110
|
+
// person doesn't hold.
|
|
111
|
+
export const allCreditsYear = (catalog, card, date) => creditsYear(catalog, card, card.credits, date);
|
|
112
|
+
// The credits a cardholder uses, in full each period: what counts against
|
|
113
|
+
// the fee of a card they hold.
|
|
114
|
+
export const usedCreditsYear = (catalog, card, held, date) => creditsYear(catalog, card, card.credits.filter((c) => creditInUse(c, held)), date);
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
2
|
import { EPSILON } from "./compare.js";
|
|
3
3
|
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
4
|
+
import { effectiveMemberships } from "./memberships.js";
|
|
4
5
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
6
|
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
7
|
const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
@@ -16,7 +17,7 @@ export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
|
16
17
|
country: purchase.country,
|
|
17
18
|
date: purchase.date,
|
|
18
19
|
amount: purchase.amount,
|
|
19
|
-
memberships: new Set(wallet.memberships),
|
|
20
|
+
memberships: new Set(effectiveMemberships(catalog, wallet.memberships, wallet.cards.map((c) => c.id))),
|
|
20
21
|
payment,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -120,6 +121,9 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
120
121
|
return 'no';
|
|
121
122
|
if (!inEffect(rule, ctx.date))
|
|
122
123
|
return 'no';
|
|
124
|
+
if (rule.min_amount !== undefined &&
|
|
125
|
+
(ctx.amount === null || ctx.amount < rule.min_amount))
|
|
126
|
+
return 'no';
|
|
123
127
|
const membership = rule.requires?.membership;
|
|
124
128
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
125
129
|
return 'no';
|
|
@@ -249,3 +253,19 @@ export function paymentUpgrade(card, held, ctx, rate) {
|
|
|
249
253
|
}
|
|
250
254
|
return best;
|
|
251
255
|
}
|
|
256
|
+
// The smallest purchase amount (one a rule on the card asks for) that would
|
|
257
|
+
// earn more than `rate`, and what it would earn. Only while the purchase has
|
|
258
|
+
// no amount.
|
|
259
|
+
export function amountUpgrade(card, held, ctx, rate) {
|
|
260
|
+
if (ctx.amount !== null)
|
|
261
|
+
return null;
|
|
262
|
+
const amounts = [
|
|
263
|
+
...new Set(card.rules.flatMap((r) => r.min_amount ?? [])),
|
|
264
|
+
].sort((a, b) => a - b);
|
|
265
|
+
for (const amount of amounts) {
|
|
266
|
+
const larger = evaluateCard(card, held, { ...ctx, amount }).best;
|
|
267
|
+
if (larger.rate > rate + EPSILON)
|
|
268
|
+
return { amount, rate: larger.rate, nominalRate: larger.nominalRate };
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
|
-
import { creditCovers, creditPerYear } from "./credits.js";
|
|
3
|
-
import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
2
|
+
import { creditCovers, creditPerYear, liveCredits } from "./credits.js";
|
|
3
|
+
import { amountUpgrade, 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";
|
|
@@ -8,7 +8,7 @@ import { FOREIGN_ASSUMPTION, foreignFeeFor, isForeign, recommend, } from "./reco
|
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
9
|
import { membershipsUsed } from "./setup.js";
|
|
10
10
|
import { optionName } from "./rule-text.js";
|
|
11
|
-
import { categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed,
|
|
11
|
+
import { amountUnknownText, capSentence, categoryPhrase, choiceName, codesAs, creditBack, creditThen, creditTerms, earnText, listed, paidText, PAYMENT_PHRASES, paymentUnknownText, pickCondition, pointValuesText, } from "./text.js";
|
|
12
12
|
import { cardValuation } from "./valuation.js";
|
|
13
13
|
import { EPSILON } from "./compare.js";
|
|
14
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -93,7 +93,12 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
93
93
|
const cap = best.cap?.cap;
|
|
94
94
|
return {
|
|
95
95
|
pick: choice && picked
|
|
96
|
-
? {
|
|
96
|
+
? {
|
|
97
|
+
choice: choice.id,
|
|
98
|
+
options: picked,
|
|
99
|
+
period: choice.period,
|
|
100
|
+
automatic: choice.automatic === true,
|
|
101
|
+
}
|
|
97
102
|
: null,
|
|
98
103
|
activation: rule.requires?.activation === true ? rule.id : null,
|
|
99
104
|
membership: rule.requires?.membership ?? null,
|
|
@@ -110,7 +115,7 @@ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
|
|
|
110
115
|
function conditionsText(catalog, card, r) {
|
|
111
116
|
const conditions = [];
|
|
112
117
|
if (r.pick) {
|
|
113
|
-
conditions.push(
|
|
118
|
+
conditions.push(`${pickCondition({ pick: card.choices.find((c) => c.id === r.pick?.choice)?.pick ?? 1, ...r.pick }, r.pick.options.map((o) => optionName(catalog, card, r.pick?.choice ?? '', o)).join(' or '), choiceName(r.pick.choice))}.`);
|
|
114
119
|
}
|
|
115
120
|
if (r.activation !== null)
|
|
116
121
|
conditions.push('Needs its bonus activated with the issuer (free).');
|
|
@@ -169,6 +174,7 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
169
174
|
};
|
|
170
175
|
const ranked = [];
|
|
171
176
|
const ifPaid = new Map();
|
|
177
|
+
const ifLarger = new Map();
|
|
172
178
|
const excluded = [];
|
|
173
179
|
// A Costco purchase means a Costco member: no membership to point out.
|
|
174
180
|
const members = paidMemberships(catalog, {
|
|
@@ -192,6 +198,14 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
192
198
|
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
193
199
|
const gap = rotationGap(card, query.date);
|
|
194
200
|
const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
201
|
+
const larger = amountUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
202
|
+
if (larger)
|
|
203
|
+
ifLarger.set(card.id, {
|
|
204
|
+
amount: larger.amount,
|
|
205
|
+
earn: earnText(catalog, larger.nominalRate, top.currency),
|
|
206
|
+
standard: earnText(catalog, top.rate, top.currency),
|
|
207
|
+
returnPct: larger.rate * top.centsPerUnit - (top.foreignFee ?? 0),
|
|
208
|
+
});
|
|
195
209
|
if (paid)
|
|
196
210
|
ifPaid.set(card.id, {
|
|
197
211
|
payment: paid.payment,
|
|
@@ -233,17 +247,36 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
233
247
|
ranked.sort(compareExplored);
|
|
234
248
|
return {
|
|
235
249
|
merchant,
|
|
236
|
-
credits: creditPicks(catalog, merchant, ranked),
|
|
250
|
+
credits: creditPicks(catalog, merchant, ranked, query.date),
|
|
237
251
|
ranked,
|
|
238
252
|
excluded,
|
|
239
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
|
|
253
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger),
|
|
240
254
|
warnings: exploreWarnings(catalog, merchant),
|
|
255
|
+
ifPaying: paidAtTop(ranked, ifPaid).map(({ entry, paid }) => ({
|
|
256
|
+
cardId: entry.cardId,
|
|
257
|
+
cardName: entry.cardName,
|
|
258
|
+
payment: paid.payment,
|
|
259
|
+
earn: paid.earn,
|
|
260
|
+
})),
|
|
241
261
|
};
|
|
242
262
|
}
|
|
243
|
-
|
|
263
|
+
// Cards a way of paying would lift to the top (or that are already there).
|
|
264
|
+
function paidAtTop(ranked, ifPaid) {
|
|
265
|
+
const top = ranked[0];
|
|
266
|
+
if (!top)
|
|
267
|
+
return [];
|
|
268
|
+
return ranked.flatMap((entry) => {
|
|
269
|
+
const paid = ifPaid.get(entry.cardId);
|
|
270
|
+
return paid &&
|
|
271
|
+
(entry === top || paid.returnPct > top.effectiveReturnPct + EPSILON)
|
|
272
|
+
? [{ entry, paid }]
|
|
273
|
+
: [];
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function creditPicks(catalog, merchant, ranked, date) {
|
|
244
277
|
const picks = ranked.flatMap((entry) => {
|
|
245
278
|
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
246
|
-
return ((card
|
|
279
|
+
return ((card ? liveCredits(card, date) : [])
|
|
247
280
|
// A credit for a whole category (every restaurant) isn't a store's.
|
|
248
281
|
.filter((c) => c.match.merchants !== undefined &&
|
|
249
282
|
creditCovers(catalog, c, merchant))
|
|
@@ -263,7 +296,8 @@ function creditPicks(catalog, merchant, ranked) {
|
|
|
263
296
|
period: credit.period,
|
|
264
297
|
enroll: credit.enroll === true,
|
|
265
298
|
note: credit.note ?? null,
|
|
266
|
-
back: creditBack(credit)
|
|
299
|
+
back: `${creditBack(credit)}${creditTerms(credit)}`,
|
|
300
|
+
terms: creditTerms(credit),
|
|
267
301
|
earn,
|
|
268
302
|
text: creditThen(credit, earn),
|
|
269
303
|
evidence: evidence
|
|
@@ -274,7 +308,7 @@ function creditPicks(catalog, merchant, ranked) {
|
|
|
274
308
|
});
|
|
275
309
|
return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
|
|
276
310
|
}
|
|
277
|
-
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
|
|
311
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid, ifLarger) {
|
|
278
312
|
const assumptions = [];
|
|
279
313
|
if (merchant.source === 'category') {
|
|
280
314
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -294,13 +328,16 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid
|
|
|
294
328
|
if (purchase.payment)
|
|
295
329
|
assumptions.push(paidText(purchase.payment));
|
|
296
330
|
const top = ranked[0];
|
|
331
|
+
for (const { entry, paid } of paidAtTop(ranked, ifPaid)) {
|
|
332
|
+
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
333
|
+
}
|
|
297
334
|
for (const entry of ranked) {
|
|
298
|
-
const
|
|
299
|
-
if (!
|
|
335
|
+
const larger = ifLarger.get(entry.cardId);
|
|
336
|
+
if (!larger || !top)
|
|
300
337
|
continue;
|
|
301
|
-
if (entry !== top &&
|
|
338
|
+
if (entry !== top && larger.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
302
339
|
continue;
|
|
303
|
-
assumptions.push(
|
|
340
|
+
assumptions.push(amountUnknownText(entry.cardName, larger.amount, larger.earn, larger.standard));
|
|
304
341
|
}
|
|
305
342
|
return assumptions;
|
|
306
343
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listed, money, withArticle, words } from "./text.js";
|
|
1
|
+
import { listed, money, shortCardName, withArticle, words } from "./text.js";
|
|
2
2
|
export function membership(catalog, id) {
|
|
3
3
|
return catalog.memberships.find((m) => m.id === id);
|
|
4
4
|
}
|
|
@@ -11,16 +11,24 @@ export function sameGroup(catalog, a, b) {
|
|
|
11
11
|
const group = membership(catalog, a)?.group;
|
|
12
12
|
return group !== undefined && group === membership(catalog, b)?.group;
|
|
13
13
|
}
|
|
14
|
+
// The memberships a person has: the ones they've said, plus the one each of
|
|
15
|
+
// their cards can't be held without (a Costco membership for Costco
|
|
16
|
+
// Anywhere). Gates a rule's `requires.membership` as well as fees. Only the
|
|
17
|
+
// person's own cards (or those named for an answer) belong in `cardIds`.
|
|
18
|
+
export function effectiveMemberships(catalog, memberships, cardIds) {
|
|
19
|
+
const out = new Set(memberships);
|
|
20
|
+
for (const card of catalog.cards) {
|
|
21
|
+
const id = card.requires_membership;
|
|
22
|
+
if (id !== undefined && cardIds.includes(card.id))
|
|
23
|
+
out.add(id);
|
|
24
|
+
}
|
|
25
|
+
return [...out];
|
|
26
|
+
}
|
|
14
27
|
// Memberships the person already pays for: the ones they've said, the ones a
|
|
15
28
|
// card they hold needs, and the ones their spending shows (only members shop
|
|
16
29
|
// at Costco).
|
|
17
30
|
export function paidMemberships(catalog, { memberships, heldCards = [], merchantIds = [], }) {
|
|
18
|
-
const paid = new Set(memberships);
|
|
19
|
-
for (const card of catalog.cards) {
|
|
20
|
-
const id = card.requires_membership;
|
|
21
|
-
if (id !== undefined && heldCards.includes(card.id))
|
|
22
|
-
paid.add(id);
|
|
23
|
-
}
|
|
31
|
+
const paid = new Set(effectiveMemberships(catalog, memberships, heldCards));
|
|
24
32
|
for (const m of catalog.memberships)
|
|
25
33
|
if (m.members_shop_at.some((id) => merchantIds.includes(id)))
|
|
26
34
|
paid.add(m.id);
|
|
@@ -62,6 +70,10 @@ export function membershipNeeded(catalog, card, paid) {
|
|
|
62
70
|
: [];
|
|
63
71
|
}
|
|
64
72
|
export function membershipNote(catalog, m) {
|
|
65
|
-
const names = m.cardIds.map((id) => catalog.cards.find((c) => c.id === id)?.name ?? id);
|
|
66
|
-
|
|
73
|
+
const names = m.cardIds.map((id) => shortCardName(catalog.cards.find((c) => c.id === id)?.name ?? id));
|
|
74
|
+
const needs = `${listed(names)} ${names.length === 1 ? 'needs' : 'need'} ${withArticle(m.name)}`;
|
|
75
|
+
const cost = m.annualFee === 0
|
|
76
|
+
? ''
|
|
77
|
+
: `, ${money(m.annualFee)} a year, counted here like an annual fee`;
|
|
78
|
+
return `${needs}${cost}. If you have one, add it to your memberships.`;
|
|
67
79
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { periodEnd, periodKey } from "./periods.js";
|
|
2
|
-
import { listed, MILESTONE_PERIODS, money, roundCents, wholeDollars, } from "./text.js";
|
|
1
|
+
import { inEffect, periodEnd, periodKey } from "./periods.js";
|
|
2
|
+
import { listed, MILESTONE_PERIODS, shortDate, money, roundCents, wholeDollars, } from "./text.js";
|
|
3
|
+
// Spend milestones: a bonus paid once a period after a spending total. Only
|
|
4
|
+
// spending the wallet tracks counts; earnrate never assumes the rest.
|
|
5
|
+
// The card's milestones the issuer runs on `date`.
|
|
6
|
+
export const liveMilestones = (card, date) => card.milestones.filter((m) => inEffect(m, date));
|
|
3
7
|
export function milestoneProgress(card, held, date) {
|
|
4
|
-
return card.
|
|
8
|
+
return liveMilestones(card, date).map((milestone) => {
|
|
5
9
|
const period = periodKey(milestone.period, date, held.anniversary ?? null);
|
|
6
10
|
const usage = held.milestone_usage?.[milestone.id];
|
|
7
11
|
return {
|
|
@@ -45,6 +49,10 @@ export function milestonePays(m) {
|
|
|
45
49
|
}
|
|
46
50
|
// "the $15,000 calendar-year spend for 10,000 bonus points"
|
|
47
51
|
export const milestoneWhat = (m) => `the ${wholeDollars(m.spend)} ${MILESTONE_PERIODS[m.period]} spend for ${milestonePays(m)}`;
|
|
52
|
+
// " starting Jan 1, 2027" for a milestone that hasn't started by `today`.
|
|
53
|
+
export const milestoneStarts = (m, today) => m.valid_from === undefined || m.valid_from <= today
|
|
54
|
+
? ''
|
|
55
|
+
: ` starting ${shortDate(m.valid_from)}`;
|
|
48
56
|
export function reachedText(card, hit, centsPerPoint) {
|
|
49
57
|
const m = card.milestones.find((x) => x.id === hit.id);
|
|
50
58
|
if (!m)
|