earnrate 0.2.11 → 0.2.13
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 +37 -12
- package/data/CHANGELOG.md +291 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +1 -0
- package/data/cards/amex-blue-cash-everyday.yaml +29 -2
- package/data/cards/amex-blue-cash-preferred.yaml +26 -0
- package/data/cards/amex-business-gold.yaml +169 -0
- package/data/cards/amex-business-platinum.yaml +184 -0
- package/data/cards/amex-delta-skymiles-blue.yaml +91 -0
- package/data/cards/amex-delta-skymiles-gold.yaml +29 -1
- package/data/cards/amex-delta-skymiles-platinum.yaml +35 -1
- package/data/cards/amex-delta-skymiles-reserve.yaml +35 -1
- package/data/cards/amex-gold.yaml +53 -0
- package/data/cards/amex-hilton-honors-aspire.yaml +25 -1
- package/data/cards/amex-hilton-honors-business.yaml +106 -0
- package/data/cards/amex-hilton-honors-surpass.yaml +16 -1
- package/data/cards/amex-hilton-honors.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-bevy.yaml +2 -0
- package/data/cards/amex-marriott-bonvoy-brilliant.yaml +17 -1
- package/data/cards/amex-marriott-bonvoy-business.yaml +116 -0
- package/data/cards/amex-platinum.yaml +111 -0
- package/data/cards/apple-card.yaml +76 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +1 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +2 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +1 -0
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +34 -1
- package/data/cards/bank-of-america-premium-rewards.yaml +134 -0
- package/data/cards/bank-of-america-travel-rewards.yaml +123 -0
- package/data/cards/bank-of-america-unlimited-cash-rewards.yaml +83 -0
- package/data/cards/barclays-jetblue-plus.yaml +86 -0
- package/data/cards/bilt-blue.yaml +60 -0
- package/data/cards/bilt-obsidian.yaml +13 -2
- package/data/cards/bilt-palladium.yaml +86 -0
- package/data/cards/caltech-efcu-platinum-mastercard.yaml +50 -0
- package/data/cards/capital-one-kohls-card.yaml +1 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +1 -0
- package/data/cards/capital-one-quicksilver.yaml +2 -0
- package/data/cards/capital-one-rei-co-op-mastercard.yaml +62 -0
- package/data/cards/capital-one-savor.yaml +2 -0
- package/data/cards/capital-one-spark-cash-plus.yaml +80 -0
- package/data/cards/capital-one-venture-x-business.yaml +124 -0
- package/data/cards/capital-one-venture-x.yaml +32 -2
- package/data/cards/capital-one-venture.yaml +2 -0
- package/data/cards/chase-aeroplan.yaml +16 -0
- package/data/cards/chase-freedom-flex.yaml +1 -0
- package/data/cards/chase-freedom-rise.yaml +29 -0
- package/data/cards/chase-freedom-unlimited.yaml +1 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +28 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +18 -0
- package/data/cards/chase-ihg-one-rewards-premier.yaml +16 -0
- package/data/cards/chase-ihg-one-rewards.yaml +1 -0
- package/data/cards/chase-ink-business-cash.yaml +14 -0
- package/data/cards/chase-ink-business-preferred.yaml +1 -0
- package/data/cards/chase-ink-business-unlimited.yaml +80 -0
- package/data/cards/chase-marriott-bonvoy-bold.yaml +1 -0
- package/data/cards/chase-marriott-bonvoy-boundless.yaml +2 -0
- package/data/cards/chase-prime-visa.yaml +1 -0
- package/data/cards/chase-sapphire-preferred.yaml +12 -0
- package/data/cards/chase-sapphire-reserve.yaml +45 -4
- package/data/cards/chase-southwest-performance-business.yaml +2 -0
- package/data/cards/chase-southwest-plus.yaml +2 -0
- package/data/cards/chase-southwest-premier-business.yaml +2 -0
- package/data/cards/chase-southwest-premier.yaml +2 -0
- package/data/cards/chase-southwest-priority.yaml +2 -0
- package/data/cards/chase-united-business.yaml +33 -0
- package/data/cards/chase-united-club-business.yaml +41 -0
- package/data/cards/chase-united-club.yaml +41 -0
- package/data/cards/chase-united-explorer.yaml +33 -0
- package/data/cards/chase-united-gateway.yaml +1 -0
- package/data/cards/chase-united-quest.yaml +41 -0
- package/data/cards/citi-aadvantage-business.yaml +2 -0
- package/data/cards/citi-aadvantage-executive.yaml +28 -1
- package/data/cards/citi-aadvantage-globe.yaml +2 -0
- package/data/cards/citi-aadvantage-mileup.yaml +2 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +2 -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 +1 -0
- 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 +21 -1
- package/data/cards/citi-strata-premier.yaml +1 -0
- package/data/cards/citi-strata.yaml +164 -0
- package/data/cards/discover-it-cash-back.yaml +22 -3
- package/data/cards/fidelity-rewards-visa-signature.yaml +60 -0
- package/data/cards/navy-federal-cash-rewards-plus.yaml +61 -0
- package/data/cards/navy-federal-cash-rewards.yaml +60 -0
- package/data/cards/navy-federal-more-rewards-amex.yaml +92 -0
- package/data/cards/paypal-cashback-mastercard.yaml +52 -0
- package/data/cards/penfed-power-cash-rewards.yaml +47 -0
- package/data/cards/sams-club-mastercard.yaml +68 -0
- package/data/cards/sofi-unlimited-2.yaml +1 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
- package/data/cards/td-bank-target-circle-card.yaml +1 -0
- package/data/cards/usbank-altitude-connect.yaml +156 -0
- package/data/cards/usbank-altitude-go.yaml +124 -0
- package/data/cards/usbank-cash-plus.yaml +1 -0
- package/data/cards/usbank-smartly.yaml +107 -0
- package/data/cards/venmo-credit-card.yaml +151 -0
- package/data/cards/verizon-visa-card.yaml +122 -0
- package/data/cards/wells-fargo-active-cash.yaml +1 -0
- package/data/cards/wells-fargo-autograph-journey.yaml +73 -0
- package/data/cards/wells-fargo-autograph.yaml +1 -0
- package/data/categories.yaml +3 -1
- package/data/currencies.yaml +48 -0
- package/data/issuers.yaml +44 -1
- package/data/memberships.yaml +45 -0
- package/data/merchants/overrides.yaml +947 -1
- package/data/merchants/seed.yaml +1916 -62
- package/dist/cli/args.js +18 -0
- package/dist/cli/explore.js +10 -1
- package/dist/cli/import.js +2 -2
- package/dist/cli/run.js +4 -1
- package/dist/cli/wallet.js +37 -3
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +59 -0
- package/dist/data/store.js +4 -1
- package/dist/data/wallet-edit.js +28 -7
- package/dist/engine/cheat-sheet.js +17 -5
- package/dist/engine/checklist.js +38 -6
- package/dist/engine/credits.js +65 -0
- package/dist/engine/evaluate.js +57 -18
- package/dist/engine/explore.js +70 -7
- package/dist/engine/intro.js +36 -0
- package/dist/engine/offers.js +2 -1
- package/dist/engine/periods.js +10 -3
- package/dist/engine/recommend.js +101 -20
- package/dist/engine/record.js +43 -1
- package/dist/engine/rotation.js +3 -0
- package/dist/engine/rule-text.js +28 -3
- package/dist/engine/setup.js +20 -3
- package/dist/engine/simulate.js +10 -1
- package/dist/engine/text.js +107 -0
- package/dist/engine/wallet-change.js +57 -4
- package/dist/engine/wallet-summary.js +54 -6
- package/dist/format/card-art.js +21 -0
- package/dist/format/card-text.js +4 -3
- package/dist/format/cards.js +1 -0
- package/dist/format/cheat-sheet.js +2 -0
- package/dist/format/explore.js +20 -4
- package/dist/format/recommendation.js +6 -2
- package/dist/format/wallet-yaml.js +1 -0
- package/dist/format/wallet.js +15 -5
- package/dist/import/analyze.js +8 -2
- package/dist/mcp/inputs.js +13 -0
- package/dist/mcp/prompts.js +9 -1
- package/dist/mcp/schemas.js +87 -1
- package/dist/mcp/tools/account.js +3 -1
- package/dist/mcp/tools/answers.js +9 -4
- package/dist/mcp/tools/wallet.js +20 -8
- package/dist/merchants/resolve.js +8 -0
- package/dist/schema/card.js +60 -3
- package/dist/schema/common.js +1 -1
- package/dist/schema/issuer.js +4 -0
- package/dist/schema/merchant.js +3 -0
- package/dist/schema/wallet.js +11 -0
- package/package.json +12 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
|
+
import { categoryMatches } from "./evaluate.js";
|
|
3
|
+
import { periodEnd, periodKey } from "./periods.js";
|
|
4
|
+
import { creditName } from "./rule-text.js";
|
|
5
|
+
import { roundCents } from "./text.js";
|
|
6
|
+
// Only a store itself has a credit: a search for a kind of purchase
|
|
7
|
+
// ("clothing") never does.
|
|
8
|
+
// A credit naming a merchant also covers its brands (Hilton's covers Hampton).
|
|
9
|
+
export function creditCovers(catalog, credit, merchant) {
|
|
10
|
+
if (merchant.source === 'category' || merchant.source === 'none')
|
|
11
|
+
return false;
|
|
12
|
+
const named = credit.match.merchants;
|
|
13
|
+
if (named)
|
|
14
|
+
return merchantFamily(catalog, merchant.merchantId).some((id) => named.includes(id));
|
|
15
|
+
return (credit.match.categories ?? []).some((c) => categoryMatches(c, merchant.category));
|
|
16
|
+
}
|
|
17
|
+
// A credit that needs enrolling counts only once the wallet says it's done.
|
|
18
|
+
export const isEnrolled = (credit, held) => credit.enroll !== true || (held.enrolled ?? []).includes(credit.id);
|
|
19
|
+
export function creditState(catalog, credit, held, date) {
|
|
20
|
+
const anniversary = held.anniversary ?? null;
|
|
21
|
+
const period = periodKey(credit.period, date, anniversary);
|
|
22
|
+
const usage = held.credit_usage?.[credit.id];
|
|
23
|
+
const used = period !== null && usage !== undefined && usage.period === period
|
|
24
|
+
? usage.used
|
|
25
|
+
: 0;
|
|
26
|
+
return {
|
|
27
|
+
credit,
|
|
28
|
+
key: period,
|
|
29
|
+
name: creditName(catalog, credit),
|
|
30
|
+
amount: credit.amount,
|
|
31
|
+
period: credit.period,
|
|
32
|
+
enrolled: isEnrolled(credit, held),
|
|
33
|
+
used,
|
|
34
|
+
left: period === null ? null : roundCents(Math.max(0, credit.amount - used)),
|
|
35
|
+
until: period === null ? null : periodEnd(credit.period, period, anniversary),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export const creditStates = (catalog, card, held, date) => card.credits.map((c) => creditState(catalog, c, held, date));
|
|
39
|
+
export const counts = (s) => s.enrolled && s.left !== null && s.left > 0;
|
|
40
|
+
// The card's credit at this merchant, if any: one that counts first, then
|
|
41
|
+
// the one with the most left.
|
|
42
|
+
export function creditAt(catalog, card, held, merchant, date) {
|
|
43
|
+
const here = card.credits
|
|
44
|
+
.filter((c) => creditCovers(catalog, c, merchant))
|
|
45
|
+
.map((c) => creditState(catalog, c, held, date))
|
|
46
|
+
.sort((a, b) => Number(counts(b)) - Number(counts(a)) || (b.left ?? 0) - (a.left ?? 0));
|
|
47
|
+
return here[0] ?? null;
|
|
48
|
+
}
|
|
49
|
+
// Dollars back on a purchase: null without an amount, 0 when the credit
|
|
50
|
+
// doesn't count (not enrolled, or used up).
|
|
51
|
+
export function creditDollars(s, amount) {
|
|
52
|
+
if (amount === null)
|
|
53
|
+
return null;
|
|
54
|
+
if (!counts(s) || amount <= 0)
|
|
55
|
+
return 0;
|
|
56
|
+
return roundCents(Math.min(amount, s.left ?? 0));
|
|
57
|
+
}
|
|
58
|
+
const PER_YEAR = {
|
|
59
|
+
month: 12,
|
|
60
|
+
quarter: 4,
|
|
61
|
+
half_year: 2,
|
|
62
|
+
calendar_year: 1,
|
|
63
|
+
cardmember_year: 1,
|
|
64
|
+
};
|
|
65
|
+
export const creditPerYear = (c) => c.amount * PER_YEAR[c.period];
|
package/dist/engine/evaluate.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
|
+
import { merchantFamily } from "../merchants/resolve.js";
|
|
2
|
+
import { EPSILON } from "./compare.js";
|
|
3
|
+
import { introCovers, introWindows, withIntro, } from "./intro.js";
|
|
1
4
|
import { inEffect, periodKey } from "./periods.js";
|
|
5
|
+
// PayPal checkout happens online, so paying through it says the channel.
|
|
6
|
+
export const paymentChannel = (channel, payment) => channel ?? (payment === 'paypal' ? 'online' : null);
|
|
2
7
|
export function purchaseContext(catalog, wallet, merchant, category, purchase) {
|
|
8
|
+
const payment = purchase.payment ?? null;
|
|
3
9
|
return {
|
|
4
10
|
category,
|
|
5
|
-
|
|
11
|
+
merchantIds: merchantFamily(catalog, merchant.merchantId),
|
|
6
12
|
mcc: category === merchant.category && merchant.likelyMcc !== null
|
|
7
13
|
? merchant.likelyMcc
|
|
8
14
|
: singleMcc(catalog, category),
|
|
9
|
-
channel: merchant.channel,
|
|
15
|
+
channel: paymentChannel(merchant.channel, payment),
|
|
10
16
|
country: purchase.country,
|
|
11
17
|
date: purchase.date,
|
|
12
18
|
amount: purchase.amount,
|
|
13
19
|
memberships: new Set(wallet.memberships),
|
|
20
|
+
payment,
|
|
14
21
|
};
|
|
15
22
|
}
|
|
16
23
|
// A category with one typical MCC (travel agencies are 4722) implies it, the
|
|
@@ -78,18 +85,16 @@ export function isActivated(card, held, ruleId) {
|
|
|
78
85
|
function applicability(card, rule, ctx, held, picked) {
|
|
79
86
|
const { match, exclude } = rule;
|
|
80
87
|
const inCategory = (cats) => cats.some((c) => categoryMatches(c, ctx.category));
|
|
88
|
+
const named = (ids) => ids?.some((id) => ctx.merchantIds.includes(id)) ?? false;
|
|
81
89
|
if (match.categories?.length && !inCategory(match.categories))
|
|
82
90
|
return 'no';
|
|
83
|
-
if (match.merchants?.length &&
|
|
84
|
-
!(ctx.merchantId !== null && match.merchants.includes(ctx.merchantId))) {
|
|
91
|
+
if (match.merchants?.length && !named(match.merchants))
|
|
85
92
|
return 'no';
|
|
86
|
-
}
|
|
87
93
|
if (match.mccs?.length && !(ctx.mcc !== null && match.mccs.includes(ctx.mcc)))
|
|
88
94
|
return 'no';
|
|
89
95
|
if (match.choice !== undefined) {
|
|
90
96
|
const covered = picked.get(match.choice);
|
|
91
|
-
const merchantPicked =
|
|
92
|
-
(covered?.merchants.includes(ctx.merchantId) ?? false);
|
|
97
|
+
const merchantPicked = named(covered?.merchants);
|
|
93
98
|
const onlinePicked = ctx.channel === 'online' &&
|
|
94
99
|
(covered?.online.some((o) => !inCategory(o.exclude)) ?? false);
|
|
95
100
|
if (!merchantPicked &&
|
|
@@ -99,13 +104,14 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
99
104
|
}
|
|
100
105
|
if (exclude?.categories && inCategory(exclude.categories))
|
|
101
106
|
return 'no';
|
|
102
|
-
if (exclude?.merchants
|
|
103
|
-
ctx.merchantId !== null &&
|
|
104
|
-
exclude.merchants.includes(ctx.merchantId)) {
|
|
107
|
+
if (named(exclude?.merchants))
|
|
105
108
|
return 'no';
|
|
106
|
-
}
|
|
107
109
|
if (exclude?.mccs && ctx.mcc !== null && exclude.mccs.includes(ctx.mcc))
|
|
108
110
|
return 'no';
|
|
111
|
+
if (exclude?.payment &&
|
|
112
|
+
ctx.payment !== null &&
|
|
113
|
+
exclude.payment.includes(ctx.payment))
|
|
114
|
+
return 'no';
|
|
109
115
|
if (rule.channel !== 'any' && rule.channel !== ctx.channel)
|
|
110
116
|
return 'no';
|
|
111
117
|
if (rule.geography === 'us_only' && ctx.country !== 'US')
|
|
@@ -117,6 +123,9 @@ function applicability(card, rule, ctx, held, picked) {
|
|
|
117
123
|
const membership = rule.requires?.membership;
|
|
118
124
|
if (membership !== undefined && !ctx.memberships.has(membership))
|
|
119
125
|
return 'no';
|
|
126
|
+
const payment = rule.requires?.payment;
|
|
127
|
+
if (payment !== undefined && ctx.payment !== payment)
|
|
128
|
+
return 'no';
|
|
120
129
|
if (rule.requires?.activation && !isActivated(card, held, rule.id))
|
|
121
130
|
return 'needs-activation';
|
|
122
131
|
return 'applies';
|
|
@@ -167,10 +176,16 @@ function beats(a, aRate, b, bRate) {
|
|
|
167
176
|
}
|
|
168
177
|
export function evaluateCard(card, held, ctx) {
|
|
169
178
|
const { picked, staleChoice } = pickedCategories(card, held, ctx.date);
|
|
179
|
+
const windows = introWindows(card, held, ctx.date);
|
|
180
|
+
const introFor = (ruleId) => windows.filter((w) => introCovers(w.intro, ruleId));
|
|
181
|
+
const rateOf = (rule) => withIntro(rule.rate, rule.id, windows);
|
|
182
|
+
const baseRate = withIntro(card.base_rate, null, windows);
|
|
170
183
|
const base = {
|
|
171
184
|
rule: null,
|
|
172
|
-
nominalRate:
|
|
173
|
-
|
|
185
|
+
nominalRate: baseRate,
|
|
186
|
+
standardRate: card.base_rate,
|
|
187
|
+
intro: introFor(null),
|
|
188
|
+
rate: baseRate,
|
|
174
189
|
cap: null,
|
|
175
190
|
afterCap: null,
|
|
176
191
|
};
|
|
@@ -187,14 +202,19 @@ export function evaluateCard(card, held, ctx) {
|
|
|
187
202
|
else
|
|
188
203
|
applying.push(rule);
|
|
189
204
|
}
|
|
190
|
-
const uncapped = Math.max(
|
|
205
|
+
const uncapped = Math.max(baseRate, ...applying.filter((r) => r.cap === undefined).map(rateOf));
|
|
191
206
|
for (const rule of applying) {
|
|
192
207
|
const cap = capState(rule, card, held, ctx.date);
|
|
193
|
-
const
|
|
208
|
+
const rate = rateOf(rule);
|
|
209
|
+
const afterCap = cap
|
|
210
|
+
? Math.max(withIntro(cap.cap.then_rate, rule.id, windows, true), uncapped)
|
|
211
|
+
: null;
|
|
194
212
|
const earning = {
|
|
195
213
|
rule,
|
|
196
|
-
nominalRate:
|
|
197
|
-
|
|
214
|
+
nominalRate: rate,
|
|
215
|
+
standardRate: rule.rate,
|
|
216
|
+
intro: introFor(rule.id),
|
|
217
|
+
rate: rateAfterCap(rate, cap, afterCap ?? rate, ctx.amount),
|
|
198
218
|
cap,
|
|
199
219
|
afterCap,
|
|
200
220
|
};
|
|
@@ -206,7 +226,26 @@ export function evaluateCard(card, held, ctx) {
|
|
|
206
226
|
return {
|
|
207
227
|
best,
|
|
208
228
|
nominal,
|
|
209
|
-
needsActivation: blocked.filter((r) => r
|
|
229
|
+
needsActivation: blocked.filter((r) => rateOf(r) > best.rate),
|
|
210
230
|
staleChoice,
|
|
211
231
|
};
|
|
212
232
|
}
|
|
233
|
+
// The way of paying (one a rule on the card requires) that would earn the
|
|
234
|
+
// most on this purchase, when that beats `rate`. Only while the purchase
|
|
235
|
+
// doesn't say how it's paid.
|
|
236
|
+
export function paymentUpgrade(card, held, ctx, rate) {
|
|
237
|
+
if (ctx.payment !== null)
|
|
238
|
+
return null;
|
|
239
|
+
let best = null;
|
|
240
|
+
const methods = new Set(card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]));
|
|
241
|
+
for (const payment of methods) {
|
|
242
|
+
const paid = evaluateCard(card, held, {
|
|
243
|
+
...ctx,
|
|
244
|
+
payment,
|
|
245
|
+
channel: paymentChannel(ctx.channel, payment),
|
|
246
|
+
}).best;
|
|
247
|
+
if (paid.rate > (best?.rate ?? rate) + EPSILON)
|
|
248
|
+
best = { payment, rate: paid.rate, nominalRate: paid.nominalRate };
|
|
249
|
+
}
|
|
250
|
+
return best;
|
|
251
|
+
}
|
package/dist/engine/explore.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { categoryResolution, resolvePlace } from "../merchants/resolve.js";
|
|
2
|
-
import {
|
|
2
|
+
import { creditCovers, creditPerYear } from "./credits.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { creditName } from "./rule-text.js";
|
|
3
5
|
import { membershipName, membershipNeeded, paidMemberships, } from "./memberships.js";
|
|
4
6
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
7
|
import { FOREIGN_ASSUMPTION, isForeign, recommend } from "./recommend.js";
|
|
6
8
|
import { rotationGap } from "./rotation.js";
|
|
7
9
|
import { membershipsUsed } from "./setup.js";
|
|
8
10
|
import { optionName } from "./rule-text.js";
|
|
9
|
-
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, earnText, listed, money, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
11
|
+
import { CAP_PERIODS, categoryPhrase, choiceName, codesAs, creditBack, creditThen, earnText, listed, money, PAYMENT_PHRASES, paidText, paymentUnknownText, PICK_PERIODS, pointValuesText, } from "./text.js";
|
|
10
12
|
import { cardValuation } from "./valuation.js";
|
|
11
13
|
import { EPSILON } from "./compare.js";
|
|
12
14
|
export const ALL_PERSONAL_CARDS = {
|
|
@@ -163,11 +165,13 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
163
165
|
channel: query.channel,
|
|
164
166
|
date: query.date,
|
|
165
167
|
country: query.country ?? 'US',
|
|
168
|
+
payment: query.payment ?? null,
|
|
166
169
|
};
|
|
167
170
|
const ranked = [];
|
|
171
|
+
const ifPaid = new Map();
|
|
168
172
|
const excluded = [];
|
|
169
173
|
// A Costco purchase means a Costco member: no membership to point out.
|
|
170
|
-
const
|
|
174
|
+
const members = paidMemberships(catalog, {
|
|
171
175
|
memberships: options.memberships,
|
|
172
176
|
merchantIds: [merchant.merchantId],
|
|
173
177
|
});
|
|
@@ -177,7 +181,9 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
177
181
|
const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
|
|
178
182
|
const held = idealHolding(card, query.date, picks);
|
|
179
183
|
const wallet = walletOf(held, options);
|
|
180
|
-
const rec = recommend(catalog, wallet, purchase, merchant
|
|
184
|
+
const rec = recommend(catalog, wallet, purchase, merchant, {
|
|
185
|
+
credits: false,
|
|
186
|
+
});
|
|
181
187
|
const top = rec.ranked[0];
|
|
182
188
|
if (!top) {
|
|
183
189
|
excluded.push(...rec.excluded);
|
|
@@ -185,6 +191,16 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
185
191
|
}
|
|
186
192
|
const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
|
|
187
193
|
const gap = rotationGap(card, query.date);
|
|
194
|
+
const paid = paymentUpgrade(card, held, purchaseContext(catalog, wallet, merchant, merchant.category, purchase), top.rate);
|
|
195
|
+
if (paid)
|
|
196
|
+
ifPaid.set(card.id, {
|
|
197
|
+
payment: paid.payment,
|
|
198
|
+
earn: earnText(catalog, paid.nominalRate, top.currency),
|
|
199
|
+
standard: earnText(catalog, top.rate, top.currency),
|
|
200
|
+
returnPct: paid.rate * top.centsPerUnit - (top.foreignFee ?? 0),
|
|
201
|
+
});
|
|
202
|
+
const payment = card.rules.find((r) => r.id === top.matchedRule)?.requires
|
|
203
|
+
?.payment;
|
|
188
204
|
const evidenceRule = top.matchedRule ?? 'base_rate';
|
|
189
205
|
const evidence = card.evidence.find((e) => e.rule === evidenceRule);
|
|
190
206
|
ranked.push({
|
|
@@ -203,7 +219,8 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
203
219
|
requirements,
|
|
204
220
|
conditions: [
|
|
205
221
|
...conditionsText(catalog, card, requirements),
|
|
206
|
-
...
|
|
222
|
+
...(payment === undefined ? [] : [`Paid ${PAYMENT_PHRASES[payment]}.`]),
|
|
223
|
+
...membershipNeeded(catalog, card, members),
|
|
207
224
|
],
|
|
208
225
|
evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
|
|
209
226
|
withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
|
|
@@ -212,13 +229,48 @@ export function exploreCards(catalog, query, options, resolved) {
|
|
|
212
229
|
ranked.sort(compareExplored);
|
|
213
230
|
return {
|
|
214
231
|
merchant,
|
|
232
|
+
credits: creditPicks(catalog, merchant, ranked),
|
|
215
233
|
ranked,
|
|
216
234
|
excluded,
|
|
217
|
-
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase),
|
|
235
|
+
assumptions: exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid),
|
|
218
236
|
warnings: exploreWarnings(catalog, merchant),
|
|
219
237
|
};
|
|
220
238
|
}
|
|
221
|
-
function
|
|
239
|
+
function creditPicks(catalog, merchant, ranked) {
|
|
240
|
+
const picks = ranked.flatMap((entry) => {
|
|
241
|
+
const card = catalog.cards.find((c) => c.id === entry.cardId);
|
|
242
|
+
return ((card?.credits ?? [])
|
|
243
|
+
// A credit for a whole category (every restaurant) isn't a store's.
|
|
244
|
+
.filter((c) => c.match.merchants !== undefined &&
|
|
245
|
+
creditCovers(catalog, c, merchant))
|
|
246
|
+
.map((credit) => {
|
|
247
|
+
const earn = earnText(catalog, entry.rate, entry.currency);
|
|
248
|
+
const evidence = card?.evidence.find((e) => e.rule === credit.id);
|
|
249
|
+
return {
|
|
250
|
+
cardId: entry.cardId,
|
|
251
|
+
cardName: entry.cardName,
|
|
252
|
+
issuer: entry.issuer,
|
|
253
|
+
network: entry.network,
|
|
254
|
+
annualFee: entry.annualFee,
|
|
255
|
+
firstYearFee: entry.firstYearFee,
|
|
256
|
+
creditId: credit.id,
|
|
257
|
+
name: creditName(catalog, credit),
|
|
258
|
+
amount: credit.amount,
|
|
259
|
+
period: credit.period,
|
|
260
|
+
enroll: credit.enroll === true,
|
|
261
|
+
note: credit.note ?? null,
|
|
262
|
+
back: creditBack(credit),
|
|
263
|
+
earn,
|
|
264
|
+
text: creditThen(credit, earn),
|
|
265
|
+
evidence: evidence
|
|
266
|
+
? { quote: evidence.quote, url: evidence.url }
|
|
267
|
+
: null,
|
|
268
|
+
};
|
|
269
|
+
}));
|
|
270
|
+
});
|
|
271
|
+
return picks.sort((a, b) => creditPerYear(b) - creditPerYear(a) || a.cardId.localeCompare(b.cardId));
|
|
272
|
+
}
|
|
273
|
+
function exploreAssumptions(catalog, merchant, ranked, options, purchase, ifPaid) {
|
|
222
274
|
const assumptions = [];
|
|
223
275
|
if (merchant.source === 'category') {
|
|
224
276
|
assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
|
|
@@ -235,6 +287,17 @@ function exploreAssumptions(catalog, merchant, ranked, options, purchase) {
|
|
|
235
287
|
assumptions.push(values);
|
|
236
288
|
if (isForeign(purchase))
|
|
237
289
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
290
|
+
if (purchase.payment)
|
|
291
|
+
assumptions.push(paidText(purchase.payment));
|
|
292
|
+
const top = ranked[0];
|
|
293
|
+
for (const entry of ranked) {
|
|
294
|
+
const paid = ifPaid.get(entry.cardId);
|
|
295
|
+
if (!paid || !top)
|
|
296
|
+
continue;
|
|
297
|
+
if (entry !== top && paid.returnPct <= top.effectiveReturnPct + EPSILON)
|
|
298
|
+
continue;
|
|
299
|
+
assumptions.push(paymentUnknownText(entry.cardName, paid.payment, paid.earn, paid.standard));
|
|
300
|
+
}
|
|
238
301
|
return assumptions;
|
|
239
302
|
}
|
|
240
303
|
function exploreWarnings(catalog, merchant) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function addDays(date, days) {
|
|
2
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
3
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
4
|
+
return t.toISOString().slice(0, 10);
|
|
5
|
+
}
|
|
6
|
+
// Never assumed: a card without a date is ranked at its standard rates.
|
|
7
|
+
export const openedOn = (held) => held.opened ?? held.bonus?.opened ?? null;
|
|
8
|
+
// The opening day is day 1, so 365 days run through the day before the first
|
|
9
|
+
// anniversary.
|
|
10
|
+
export const introUntil = (intro, opened) => addDays(opened, intro.days - 1);
|
|
11
|
+
// The card's intro rates in effect on `date`.
|
|
12
|
+
export function introWindows(card, held, date) {
|
|
13
|
+
const opened = openedOn(held);
|
|
14
|
+
if (opened === null || date < opened)
|
|
15
|
+
return [];
|
|
16
|
+
return card.intro_rates.flatMap((intro) => {
|
|
17
|
+
const until = introUntil(intro, opened);
|
|
18
|
+
return date <= until ? [{ intro, until }] : [];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
// Whether an intro rate changes this rule's rate (null: the base rate). Past
|
|
22
|
+
// a cap only a whole-card intro applies.
|
|
23
|
+
export const introCovers = (intro, ruleId, pastCap = false) => intro.rules === undefined ||
|
|
24
|
+
(!pastCap && ruleId !== null && intro.rules.includes(ruleId));
|
|
25
|
+
export function withIntro(rate, ruleId, windows, pastCap = false) {
|
|
26
|
+
let out = rate;
|
|
27
|
+
for (const { intro } of windows) {
|
|
28
|
+
if (!introCovers(intro, ruleId, pastCap))
|
|
29
|
+
continue;
|
|
30
|
+
out =
|
|
31
|
+
intro.multiply !== undefined
|
|
32
|
+
? out * intro.multiply
|
|
33
|
+
: out + (intro.add ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
package/dist/engine/offers.js
CHANGED
|
@@ -2,7 +2,7 @@ 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 { money, roundCents } from "./text.js";
|
|
5
|
+
import { CREDITS_LEFT_OUT, 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
|
|
@@ -83,6 +83,7 @@ export function compareOffers(catalog, wallet, filters, today) {
|
|
|
83
83
|
`Compares the ${String(catalog.cards.length)} cards earnrate knows, not every card on the market.`,
|
|
84
84
|
`Each offer is the public one on the issuer's page on the date shown, checked weekly. ${OFFERS_MAY_DIFFER} Issuers also limit who can get a bonus; each card's note says how.`,
|
|
85
85
|
"First-year value is the bonus at your point values minus the first year's annual fee and any membership the card needs that you don't have. It leaves out what the card earns on your spending and any credits or perks.",
|
|
86
|
+
CREDITS_LEFT_OUT,
|
|
86
87
|
];
|
|
87
88
|
return { offers, withoutOffer, notes };
|
|
88
89
|
}
|
package/dist/engine/periods.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Keys match the wallet's PeriodKey format: "2026"
|
|
2
|
-
// year is keyed by the calendar year it
|
|
3
|
-
// cardmember year is asked for without an
|
|
1
|
+
// Keys match the wallet's PeriodKey format: "2026", "2026-Q4" or "2026-H2"
|
|
2
|
+
// (July to December). A cardmember year is keyed by the calendar year it
|
|
3
|
+
// started in. Returns null when a cardmember year is asked for without an
|
|
4
|
+
// anniversary (MM-DD).
|
|
4
5
|
export function periodKey(period, date, anniversary) {
|
|
5
6
|
const year = Number(date.slice(0, 4));
|
|
6
7
|
const month = Number(date.slice(5, 7));
|
|
@@ -11,6 +12,8 @@ export function periodKey(period, date, anniversary) {
|
|
|
11
12
|
return String(year);
|
|
12
13
|
case 'quarter':
|
|
13
14
|
return `${String(year)}-Q${String(Math.floor((month - 1) / 3) + 1)}`;
|
|
15
|
+
case 'half_year':
|
|
16
|
+
return `${String(year)}-H${month <= 6 ? '1' : '2'}`;
|
|
14
17
|
case 'cardmember_year':
|
|
15
18
|
if (anniversary === null)
|
|
16
19
|
return null;
|
|
@@ -57,6 +60,10 @@ export function periodEnd(period, key, anniversary) {
|
|
|
57
60
|
const q = /^(\d{4})-Q([1-4])$/.exec(key);
|
|
58
61
|
return q ? utcDay(Number(q[1]), Number(q[2]) * 3 + 1, 0) : null;
|
|
59
62
|
}
|
|
63
|
+
case 'half_year': {
|
|
64
|
+
const h = /^(\d{4})-H([12])$/.exec(key);
|
|
65
|
+
return h ? utcDay(Number(h[1]), Number(h[2]) * 6 + 1, 0) : null;
|
|
66
|
+
}
|
|
60
67
|
case 'calendar_year':
|
|
61
68
|
return /^\d{4}$/.test(key) ? `${key}-12-31` : null;
|
|
62
69
|
case 'cardmember_year': {
|