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
package/dist/engine/recommend.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
1
|
+
import { findMerchant, merchantFamily, resolveMerchant, resolveMerchantById, } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
|
-
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
3
|
+
import { evaluateCard, paymentUpgrade, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
|
+
import { openedOn } from "./intro.js";
|
|
4
5
|
import { membershipName } from "./memberships.js";
|
|
5
6
|
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
6
7
|
import { daysBetween } from "./periods.js";
|
|
8
|
+
import { creditAt, creditDollars } from "./credits.js";
|
|
7
9
|
import { ruleWhat } from "./rule-text.js";
|
|
8
10
|
import { rotationGap } from "./rotation.js";
|
|
9
11
|
import { EPSILON, isClose } from "./compare.js";
|
|
10
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
12
|
+
import { CAP_ADJECTIVES, categoryPhrase, creditAnswerText, codesAs, currencyLabel, currencyName, earnText, introRateText, introUnknownText, listed, money, NETWORK_NAMES, num, paidText, paymentUnknownText, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
11
13
|
import { cardValuation } from "./valuation.js";
|
|
12
14
|
export const STALE_AFTER_DAYS = 180;
|
|
13
15
|
// The engine only tells the U.S. from everywhere else.
|
|
@@ -41,7 +43,7 @@ function compareEntries(a, b) {
|
|
|
41
43
|
return aCapped ? 1 : -1;
|
|
42
44
|
return a.card.id.localeCompare(b.card.id);
|
|
43
45
|
}
|
|
44
|
-
function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
46
|
+
function rankEntries(catalog, wallet, purchase, merchant, category, options = {}) {
|
|
45
47
|
const ctx = purchaseContext(catalog, wallet, merchant, category, purchase);
|
|
46
48
|
const entries = [];
|
|
47
49
|
const excluded = [];
|
|
@@ -79,6 +81,26 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
79
81
|
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
80
82
|
// An unknown fee isn't guessed: it's warned about instead.
|
|
81
83
|
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
84
|
+
const fresh = card.intro_rates.length > 0 && openedOn(held) === null
|
|
85
|
+
? evaluateCard(card, { ...held, opened: purchase.date }, ctx).best
|
|
86
|
+
: null;
|
|
87
|
+
const ifNew = fresh && fresh.rate > evaluation.best.rate + EPSILON
|
|
88
|
+
? {
|
|
89
|
+
rate: fresh.nominalRate,
|
|
90
|
+
returnPct: (perDollar - evaluation.best.rate + fresh.rate) *
|
|
91
|
+
valuation.centsPerUnit -
|
|
92
|
+
fee,
|
|
93
|
+
days: (fresh.intro[0]?.intro ?? card.intro_rates[0])?.days ?? 365,
|
|
94
|
+
}
|
|
95
|
+
: null;
|
|
96
|
+
const paid = paymentUpgrade(card, held, ctx, evaluation.best.rate);
|
|
97
|
+
const credit = options.credits === false
|
|
98
|
+
? null
|
|
99
|
+
: creditAt(catalog, card, held, { ...merchant, category }, purchase.date);
|
|
100
|
+
const back = credit ? creditDollars(credit, purchase.amount) : null;
|
|
101
|
+
const creditPct = back !== null && back > 0 && purchase.amount
|
|
102
|
+
? (back / purchase.amount) * 100
|
|
103
|
+
: 0;
|
|
82
104
|
entries.push({
|
|
83
105
|
card,
|
|
84
106
|
held,
|
|
@@ -87,9 +109,25 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
87
109
|
milestones,
|
|
88
110
|
perDollar,
|
|
89
111
|
fee,
|
|
90
|
-
|
|
112
|
+
credit,
|
|
113
|
+
creditDollars: back,
|
|
114
|
+
creditPct,
|
|
115
|
+
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee + creditPct,
|
|
91
116
|
rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
|
|
92
|
-
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
117
|
+
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) -
|
|
118
|
+
fee +
|
|
119
|
+
creditPct,
|
|
120
|
+
ifNew: ifNew && { ...ifNew, returnPct: ifNew.returnPct + creditPct },
|
|
121
|
+
ifPaid: paid
|
|
122
|
+
? {
|
|
123
|
+
payment: paid.payment,
|
|
124
|
+
rate: paid.nominalRate,
|
|
125
|
+
returnPct: (perDollar - evaluation.best.rate + paid.rate) *
|
|
126
|
+
valuation.centsPerUnit -
|
|
127
|
+
fee +
|
|
128
|
+
creditPct,
|
|
129
|
+
}
|
|
130
|
+
: null,
|
|
93
131
|
});
|
|
94
132
|
}
|
|
95
133
|
entries.sort(compareEntries);
|
|
@@ -105,7 +143,12 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
105
143
|
lines.push(`${earnText(catalog, best.nominalRate, currency)} on ${ruleWhat(catalog, best.rule)}${best.rule.requires?.membership === undefined ? '' : `, with ${membershipName(catalog, best.rule.requires.membership)}`}.${note}`);
|
|
106
144
|
}
|
|
107
145
|
else {
|
|
108
|
-
lines.push(`${earnText(catalog,
|
|
146
|
+
lines.push(`${earnText(catalog, best.nominalRate, currency)}, its base rate. No bonus category applies here.`);
|
|
147
|
+
}
|
|
148
|
+
const [intro] = best.intro;
|
|
149
|
+
if (intro) {
|
|
150
|
+
const note = intro.intro.note ? ` ${intro.intro.note}` : '';
|
|
151
|
+
lines.push(`${introRateText(earnText(catalog, best.nominalRate, currency), earnText(catalog, best.standardRate, currency), intro.intro.days, intro.until)}${note}`);
|
|
109
152
|
}
|
|
110
153
|
const cap = best.cap;
|
|
111
154
|
if (cap && amount !== null && amount > cap.remaining) {
|
|
@@ -114,6 +157,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
114
157
|
}
|
|
115
158
|
for (const hit of entry.milestones)
|
|
116
159
|
lines.push(reachedText(card, hit, valuation.centsPerUnit));
|
|
160
|
+
if (entry.credit)
|
|
161
|
+
lines.push(creditAnswerText(entry.credit, entry.creditDollars));
|
|
117
162
|
if (valuation.pooledFrom !== null) {
|
|
118
163
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
119
164
|
const heldPartners = wallet.cards
|
|
@@ -199,6 +244,16 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
199
244
|
entry.valuation.centsPerUnit) /
|
|
200
245
|
100),
|
|
201
246
|
},
|
|
247
|
+
credit: entry.credit && {
|
|
248
|
+
id: entry.credit.credit.id,
|
|
249
|
+
name: entry.credit.name,
|
|
250
|
+
amount: entry.credit.amount,
|
|
251
|
+
period: entry.credit.period,
|
|
252
|
+
enrolled: entry.credit.enrolled,
|
|
253
|
+
left: entry.credit.left,
|
|
254
|
+
until: entry.credit.until,
|
|
255
|
+
dollars: entry.creditDollars,
|
|
256
|
+
},
|
|
202
257
|
capped: evaluation.best.cap !== null,
|
|
203
258
|
reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
|
|
204
259
|
warnings: warningsFor(catalog, entry, purchase),
|
|
@@ -243,15 +298,16 @@ function sensitivity(catalog, entries) {
|
|
|
243
298
|
continue;
|
|
244
299
|
const wRate = winner.perDollar;
|
|
245
300
|
const oRate = other.perDollar;
|
|
246
|
-
// Net return is rate × value − fee, so each flip point adds the
|
|
301
|
+
// Net return is rate × value − fee + credit, so each flip point adds the
|
|
302
|
+
// fee back and takes the credit off.
|
|
247
303
|
if (w.range && wRate > 0) {
|
|
248
|
-
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
304
|
+
const threshold = (other.effectiveReturnPct + winner.fee - winner.creditPct) / wRate;
|
|
249
305
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
250
306
|
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
251
307
|
}
|
|
252
308
|
}
|
|
253
309
|
if (o.range && oRate > 0) {
|
|
254
|
-
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
310
|
+
const threshold = (winner.effectiveReturnPct + other.fee - other.creditPct) / oRate;
|
|
255
311
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
256
312
|
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
257
313
|
}
|
|
@@ -273,8 +329,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
273
329
|
const naming = entries.flatMap((e) => {
|
|
274
330
|
const rule = e.evaluation.best.rule;
|
|
275
331
|
return rule &&
|
|
276
|
-
merchant.merchantId
|
|
277
|
-
(rule.match.merchants ?? []).includes(merchant.merchantId)
|
|
332
|
+
merchantFamily(catalog, merchant.merchantId).some((id) => (rule.match.merchants ?? []).includes(id))
|
|
278
333
|
? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
|
|
279
334
|
: [];
|
|
280
335
|
});
|
|
@@ -291,6 +346,9 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
291
346
|
if (purchase.channel === null && merchant.channel === 'in_store') {
|
|
292
347
|
assumptions.push(`Assumes an in-store purchase. ${merchant.resolvedName} takes cards differently online.`);
|
|
293
348
|
}
|
|
349
|
+
if (merchant.channel === null && purchase.payment === 'paypal') {
|
|
350
|
+
assumptions.push('Assumes an online purchase, since PayPal checkout is online.');
|
|
351
|
+
}
|
|
294
352
|
if (purchase.channel === null && merchant.channel === 'online') {
|
|
295
353
|
assumptions.push('Assumes an online purchase, since you gave a web address.');
|
|
296
354
|
}
|
|
@@ -299,13 +357,34 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
299
357
|
assumptions.push(values);
|
|
300
358
|
if (isForeign(purchase))
|
|
301
359
|
assumptions.push(FOREIGN_ASSUMPTION);
|
|
360
|
+
if (purchase.payment)
|
|
361
|
+
assumptions.push(paidText(purchase.payment));
|
|
362
|
+
const winner = entries[0];
|
|
363
|
+
for (const e of entries) {
|
|
364
|
+
if (!e.ifPaid || !winner)
|
|
365
|
+
continue;
|
|
366
|
+
if (e !== winner &&
|
|
367
|
+
e.ifPaid.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
368
|
+
continue;
|
|
369
|
+
const currency = e.valuation.currency;
|
|
370
|
+
assumptions.push(paymentUnknownText(displayName(e), e.ifPaid.payment, earnText(catalog, e.ifPaid.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency)));
|
|
371
|
+
}
|
|
372
|
+
for (const e of entries) {
|
|
373
|
+
if (!e.ifNew || !winner)
|
|
374
|
+
continue;
|
|
375
|
+
if (e !== winner &&
|
|
376
|
+
e.ifNew.returnPct <= winner.effectiveReturnPct + EPSILON)
|
|
377
|
+
continue;
|
|
378
|
+
const currency = e.valuation.currency;
|
|
379
|
+
assumptions.push(introUnknownText(displayName(e), earnText(catalog, e.ifNew.rate, currency), earnText(catalog, e.evaluation.best.nominalRate, currency), e.ifNew.days));
|
|
380
|
+
}
|
|
302
381
|
for (const e of entries)
|
|
303
382
|
for (const hit of e.milestones)
|
|
304
383
|
assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
|
|
305
384
|
return assumptions;
|
|
306
385
|
}
|
|
307
|
-
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
308
|
-
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category);
|
|
386
|
+
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel), options = {}) {
|
|
387
|
+
const { entries, excluded } = rankEntries(catalog, wallet, purchase, merchant, merchant.category, options);
|
|
309
388
|
const ranked = entries.map((e) => toRanked(catalog, wallet, e, purchase));
|
|
310
389
|
const winner = entries[0];
|
|
311
390
|
const warnings = [];
|
|
@@ -329,7 +408,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
329
408
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
330
409
|
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
331
410
|
for (const category of merchant.ambiguity.mayCodeAs) {
|
|
332
|
-
const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
|
|
411
|
+
const alts = rankEntries(catalog, wallet, purchase, merchant, category, options).entries;
|
|
333
412
|
const alt = alts[0];
|
|
334
413
|
if (!alt || !winner)
|
|
335
414
|
continue;
|
|
@@ -356,7 +435,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
356
435
|
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
357
436
|
: null;
|
|
358
437
|
const alt = online
|
|
359
|
-
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
438
|
+
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category, options).entries[0]
|
|
360
439
|
: undefined;
|
|
361
440
|
if (alt)
|
|
362
441
|
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
|
|
@@ -381,8 +460,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
381
460
|
const via = resolveMerchantById(catalog, id, purchase.channel);
|
|
382
461
|
if (!via || !winner)
|
|
383
462
|
continue;
|
|
384
|
-
const alt = rankEntries(catalog, wallet, purchase, via, via.category)
|
|
385
|
-
.entries[0];
|
|
463
|
+
const alt = rankEntries(catalog, wallet, purchase, via, via.category, options).entries[0];
|
|
386
464
|
if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
|
|
387
465
|
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
|
|
388
466
|
}
|
|
@@ -418,8 +496,11 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
418
496
|
const costHere = ties || top.earned === null || first.earned === null
|
|
419
497
|
? null
|
|
420
498
|
: roundCents(first.earned +
|
|
421
|
-
(first.milestoneBonus?.dollars ?? 0)
|
|
422
|
-
(
|
|
499
|
+
(first.milestoneBonus?.dollars ?? 0) +
|
|
500
|
+
(first.credit?.dollars ?? 0) -
|
|
501
|
+
(top.earned +
|
|
502
|
+
(top.milestoneBonus?.dollars ?? 0) +
|
|
503
|
+
(top.credit?.dollars ?? 0)));
|
|
423
504
|
bonusResult = {
|
|
424
505
|
cardId: top.cardId,
|
|
425
506
|
checkoutLabel: top.checkoutLabel,
|
package/dist/engine/record.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { bonusStatus, countsTowardBonus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext } from "./evaluate.js";
|
|
4
|
+
import { creditAt, creditDollars } from "./credits.js";
|
|
4
5
|
import { milestoneProgress } from "./milestones.js";
|
|
5
6
|
import { roundCents } from "./text.js";
|
|
6
7
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
@@ -29,6 +30,7 @@ export function capBooking(catalog, wallet, purchase, merchant = resolveMerchant
|
|
|
29
30
|
capKey: cap.key,
|
|
30
31
|
ruleId: rule.id,
|
|
31
32
|
period: cap.period,
|
|
33
|
+
periodKind: cap.cap.period,
|
|
32
34
|
capAmount: cap.cap.amount,
|
|
33
35
|
before,
|
|
34
36
|
after: roundCents(before + purchase.amount),
|
|
@@ -90,6 +92,7 @@ export function milestoneBookings(catalog, wallet, purchase) {
|
|
|
90
92
|
cardId: card.id,
|
|
91
93
|
milestoneId: p.milestone.id,
|
|
92
94
|
period: p.period,
|
|
95
|
+
periodKind: p.milestone.period,
|
|
93
96
|
spend: p.milestone.spend,
|
|
94
97
|
before: p.spent,
|
|
95
98
|
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
|
@@ -110,14 +113,53 @@ export function applyMilestoneBooking(wallet, b) {
|
|
|
110
113
|
: c),
|
|
111
114
|
};
|
|
112
115
|
}
|
|
116
|
+
// The part of an enrolled merchant credit this purchase uses. None when the
|
|
117
|
+
// card has no credit here, it isn't enrolled, or it's used up.
|
|
118
|
+
export function creditBooking(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
119
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
120
|
+
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
121
|
+
if (!held || !card)
|
|
122
|
+
return null;
|
|
123
|
+
const state = creditAt(catalog, card, held, merchant, purchase.date);
|
|
124
|
+
const back = state ? creditDollars(state, purchase.amount) : null;
|
|
125
|
+
if (!state || state.key === null || back === null || back <= 0)
|
|
126
|
+
return null;
|
|
127
|
+
return {
|
|
128
|
+
cardId: card.id,
|
|
129
|
+
creditId: state.credit.id,
|
|
130
|
+
name: state.name,
|
|
131
|
+
period: state.key,
|
|
132
|
+
periodKind: state.period,
|
|
133
|
+
amount: state.amount,
|
|
134
|
+
before: state.used,
|
|
135
|
+
after: roundCents(state.used + back),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function applyCreditBooking(wallet, b) {
|
|
139
|
+
return {
|
|
140
|
+
...wallet,
|
|
141
|
+
cards: wallet.cards.map((c) => c.id === b.cardId
|
|
142
|
+
? {
|
|
143
|
+
...c,
|
|
144
|
+
credit_usage: {
|
|
145
|
+
...c.credit_usage,
|
|
146
|
+
[b.creditId]: { period: b.period, used: b.after },
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
: c),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
113
152
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
114
153
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
115
154
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
116
155
|
const milestones = milestoneBookings(catalog, wallet, purchase);
|
|
156
|
+
const credit = creditBooking(catalog, wallet, purchase, merchant);
|
|
117
157
|
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
118
158
|
if (bonus)
|
|
119
159
|
next = applyBonusBooking(next, bonus);
|
|
120
160
|
for (const m of milestones)
|
|
121
161
|
next = applyMilestoneBooking(next, m);
|
|
122
|
-
|
|
162
|
+
if (credit)
|
|
163
|
+
next = applyCreditBooking(next, credit);
|
|
164
|
+
return { wallet: next, cap, bonus, milestones, credit };
|
|
123
165
|
}
|
package/dist/engine/rotation.js
CHANGED
|
@@ -14,3 +14,6 @@ export function rotationGap(card, date) {
|
|
|
14
14
|
}
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
+
// What answers say about a gap: "Freedom Flex's rotating categories for
|
|
18
|
+
// 2027-Q2 aren't in earnrate's data yet. ..."
|
|
19
|
+
export const rotationGapText = (name, gap) => `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`;
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { findMerchant } from "../merchants/resolve.js";
|
|
2
|
-
import { categoryPhrase, choiceName, listed, words } from "./text.js";
|
|
2
|
+
import { categoryPhrase, categoryReadsOn, choiceName, earnText, introPeriod, listed, num, PAYMENT_PHRASES, words, } from "./text.js";
|
|
3
3
|
// What a rule or a pick covers, in words, for answers and wallet lists.
|
|
4
4
|
// Travel-site bookings ("Flight through Amex Travel") read mid-sentence in
|
|
5
5
|
// lower case and plural; brand names ("Airbnb through Amex Travel") keep theirs.
|
|
@@ -29,7 +29,9 @@ export function ruleParts(catalog, rule) {
|
|
|
29
29
|
return [
|
|
30
30
|
rule.geography === 'foreign_only'
|
|
31
31
|
? 'purchases outside the U.S.'
|
|
32
|
-
:
|
|
32
|
+
: rule.requires?.payment !== undefined
|
|
33
|
+
? 'purchases'
|
|
34
|
+
: 'everything else',
|
|
33
35
|
];
|
|
34
36
|
if (rule.match.choice !== undefined)
|
|
35
37
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
@@ -55,5 +57,28 @@ export function ruleParts(catalog, rule) {
|
|
|
55
57
|
return parts.length > 0 ? parts : [words(rule.id)];
|
|
56
58
|
}
|
|
57
59
|
export function ruleWhat(catalog, rule) {
|
|
58
|
-
|
|
60
|
+
const payment = rule.requires?.payment;
|
|
61
|
+
const what = listed(ruleParts(catalog, rule));
|
|
62
|
+
return payment === undefined ? what : `${what} ${PAYMENT_PHRASES[payment]}`;
|
|
59
63
|
}
|
|
64
|
+
// "double every rate for the first year", "an extra 3% cash back on your 3%
|
|
65
|
+
// picks for the first year".
|
|
66
|
+
export function introWhat(catalog, card, intro) {
|
|
67
|
+
const covered = card.rules.filter((r) => intro.rules?.includes(r.id));
|
|
68
|
+
const on = intro.rules === undefined
|
|
69
|
+
? 'every rate'
|
|
70
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join('; ');
|
|
71
|
+
const change = intro.multiply === undefined
|
|
72
|
+
? `an extra ${earnText(catalog, intro.add ?? 0, card.currency)} on ${on}`
|
|
73
|
+
: intro.multiply === 2
|
|
74
|
+
? `double ${on}`
|
|
75
|
+
: `${num(intro.multiply)} times ${on}`;
|
|
76
|
+
return `${change} for the ${introPeriod(intro.days)}`;
|
|
77
|
+
}
|
|
78
|
+
// What a merchant credit is named for: "Lululemon", "Disney+, Hulu and ESPN".
|
|
79
|
+
export const creditName = (catalog, credit) => listed([
|
|
80
|
+
...(credit.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
81
|
+
...(credit.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
82
|
+
]);
|
|
83
|
+
// "at Lululemon", "at restaurants", "on travel": a category's own preposition.
|
|
84
|
+
export const creditWhere = (catalog, credit) => `${(credit.match.categories ?? []).some((c) => categoryReadsOn(catalog, c)) ? 'on' : 'at'} ${creditName(catalog, credit)}`;
|
package/dist/engine/setup.js
CHANGED
|
@@ -2,8 +2,9 @@ import { isActivated, pickedCategories } from "./evaluate.js";
|
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
3
|
import { milestoneWhat } from "./milestones.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
|
-
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import {
|
|
5
|
+
import { rotationGap, rotationGapText } from "./rotation.js";
|
|
6
|
+
import { creditName, creditWhere, introWhat } from "./rule-text.js";
|
|
7
|
+
import { creditBack, ENROLL_FIRST, idList, listed } from "./text.js";
|
|
7
8
|
// Rules that start within this many days are worth activating now.
|
|
8
9
|
const ACTIVATION_LOOKAHEAD_DAYS = 45;
|
|
9
10
|
const searchWords = (s) => s
|
|
@@ -105,6 +106,22 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
105
106
|
rewards: m.rewards ?? [],
|
|
106
107
|
text: milestoneWhat(m).replace(/^the /, ''),
|
|
107
108
|
})),
|
|
109
|
+
introRates: card.intro_rates.map((i) => ({
|
|
110
|
+
id: i.id,
|
|
111
|
+
days: i.days,
|
|
112
|
+
text: introWhat(catalog, card, i),
|
|
113
|
+
})),
|
|
114
|
+
credits: card.credits.map((c) => {
|
|
115
|
+
const name = creditName(catalog, c);
|
|
116
|
+
return {
|
|
117
|
+
id: c.id,
|
|
118
|
+
name,
|
|
119
|
+
amount: c.amount,
|
|
120
|
+
period: c.period,
|
|
121
|
+
enroll: c.enroll === true,
|
|
122
|
+
text: `${creditBack(c)} ${creditWhere(catalog, c)}${c.enroll ? `, ${ENROLL_FIRST}` : ''}`,
|
|
123
|
+
};
|
|
124
|
+
}),
|
|
108
125
|
};
|
|
109
126
|
}
|
|
110
127
|
// Every word of the query has to match a word of the card's id, name or
|
|
@@ -180,7 +197,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
180
197
|
if (gap !== null) {
|
|
181
198
|
notes.push({
|
|
182
199
|
kind: 'rotation',
|
|
183
|
-
text:
|
|
200
|
+
text: rotationGapText(name, gap),
|
|
184
201
|
});
|
|
185
202
|
}
|
|
186
203
|
return notes;
|
package/dist/engine/simulate.js
CHANGED
|
@@ -6,7 +6,7 @@ import { choicePeriod, periodKey } from "./periods.js";
|
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
7
|
import { milestoneWhat } from "./milestones.js";
|
|
8
8
|
import { rotationGap } from "./rotation.js";
|
|
9
|
-
import { money, roundCents, wholeDollars } from "./text.js";
|
|
9
|
+
import { money, paymentNote, roundCents, wholeDollars } from "./text.js";
|
|
10
10
|
import { cardValuation } from "./valuation.js";
|
|
11
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
12
12
|
const MONTHS = 12;
|
|
@@ -155,6 +155,9 @@ held = []) {
|
|
|
155
155
|
const notes = [...milestoneNotes];
|
|
156
156
|
for (const m of memberships)
|
|
157
157
|
notes.push(membershipNote(catalog, m));
|
|
158
|
+
const paying = paymentNote(cards.map(({ card }) => ({ name: card.name, card })));
|
|
159
|
+
if (paying !== null)
|
|
160
|
+
notes.push(paying);
|
|
158
161
|
const dates = monthDates(start);
|
|
159
162
|
for (const { card } of cards) {
|
|
160
163
|
const gaps = [
|
|
@@ -240,6 +243,12 @@ function bestSimulation(catalog, base, card, lines, options) {
|
|
|
240
243
|
}
|
|
241
244
|
const costs = (s) => s.annualFees + s.membershipFees;
|
|
242
245
|
const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
|
|
246
|
+
// Each card on its own with its best picks, best net first.
|
|
247
|
+
export function simulateSingles(catalog, cards, lines, options) {
|
|
248
|
+
return cards
|
|
249
|
+
.map((card) => bestSimulation(catalog, [], card, lines, options))
|
|
250
|
+
.sort(byNet);
|
|
251
|
+
}
|
|
243
252
|
// Pairs that tie and share their first card differ only in an
|
|
244
253
|
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
245
254
|
function foldTies(pairs) {
|
package/dist/engine/text.js
CHANGED
|
@@ -63,6 +63,15 @@ export function categoryPhrase(catalog, id) {
|
|
|
63
63
|
? name
|
|
64
64
|
: name.charAt(0).toLowerCase() + name.slice(1);
|
|
65
65
|
}
|
|
66
|
+
// A category bought as a service reads "on" (streaming services); its own
|
|
67
|
+
// preposition or a parent's says so. Everything else reads "at".
|
|
68
|
+
export function categoryReadsOn(catalog, id) {
|
|
69
|
+
const category = catalog.categories.find((c) => c.id === id);
|
|
70
|
+
if (category?.preposition === 'on')
|
|
71
|
+
return true;
|
|
72
|
+
const dot = id.lastIndexOf('.');
|
|
73
|
+
return dot !== -1 && categoryReadsOn(catalog, id.slice(0, dot));
|
|
74
|
+
}
|
|
66
75
|
export function earnText(catalog, rate, currency) {
|
|
67
76
|
return currency === 'cash'
|
|
68
77
|
? `${pct(rate)} cash back`
|
|
@@ -92,6 +101,32 @@ export const NETWORK_NAMES = {
|
|
|
92
101
|
discover: 'Discover',
|
|
93
102
|
store: 'store card',
|
|
94
103
|
};
|
|
104
|
+
// How a purchase is paid, mid-sentence: "2% cash back with Apple Pay".
|
|
105
|
+
export const PAYMENT_PHRASES = {
|
|
106
|
+
card: 'with the card itself',
|
|
107
|
+
'apple-pay': 'with Apple Pay',
|
|
108
|
+
'google-pay': 'with Google Pay',
|
|
109
|
+
paypal: 'through PayPal checkout',
|
|
110
|
+
};
|
|
111
|
+
export const paidText = (payment) => `Counts this as paid ${PAYMENT_PHRASES[payment]}, as you said.`;
|
|
112
|
+
export const paymentUnknownText = (name, payment, earn, standard) => `${capitalize(PAYMENT_PHRASES[payment])}, ${name} would earn ${earn} here, instead of the ${standard} it's ranked at. Say how you're paying to count it.`;
|
|
113
|
+
// For answers that never ask how each purchase is paid (cheat sheets and
|
|
114
|
+
// simulations): which cards' rates that need a way of paying are left out.
|
|
115
|
+
export function paymentNote(cards) {
|
|
116
|
+
const payments = new Set();
|
|
117
|
+
const names = [];
|
|
118
|
+
for (const { name, card } of cards) {
|
|
119
|
+
const needs = card.rules.flatMap((r) => r.requires?.payment === undefined ? [] : [r.requires.payment]);
|
|
120
|
+
if (needs.length === 0)
|
|
121
|
+
continue;
|
|
122
|
+
names.push(name);
|
|
123
|
+
for (const p of needs)
|
|
124
|
+
payments.add(p);
|
|
125
|
+
}
|
|
126
|
+
if (names.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
return `Rates ${listed([...payments].map((p) => PAYMENT_PHRASES[p]), 'or')} (${listed(names)}) aren't counted: this ranks each card by what it earns however you pay.`;
|
|
129
|
+
}
|
|
95
130
|
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
96
131
|
// list's: those lists use commas only, with those names last.
|
|
97
132
|
export const listed = (parts, joiner = 'and') => {
|
|
@@ -122,6 +157,9 @@ export const CAP_PERIODS = {
|
|
|
122
157
|
calendar_year: 'a calendar year',
|
|
123
158
|
cardmember_year: 'a cardmember year',
|
|
124
159
|
};
|
|
160
|
+
// "for 2026-Q4", or "this cardmember year": a cardmember year's key is the
|
|
161
|
+
// year it started, which reads as the calendar year.
|
|
162
|
+
export const periodPhrase = (kind, key) => kind === 'cardmember_year' ? 'this cardmember year' : `for ${key}`;
|
|
125
163
|
export const CAP_ADJECTIVES = {
|
|
126
164
|
month: 'monthly',
|
|
127
165
|
quarter: 'quarterly',
|
|
@@ -139,3 +177,72 @@ export const PICK_PERIODS = {
|
|
|
139
177
|
calendar_year: 'each year',
|
|
140
178
|
until_changed: 'until you change it',
|
|
141
179
|
};
|
|
180
|
+
// Intro rates (a card's first 365 days): "first year", "first 90 days".
|
|
181
|
+
export const introPeriod = (days) => days === 365 ? 'first year' : `first ${String(days)} days`;
|
|
182
|
+
export const introAdjective = (days) => days === 365 ? 'first-year' : `${String(days)}-day`;
|
|
183
|
+
// Asking for an opening date: "if it opened in the last year".
|
|
184
|
+
export const introSince = (days) => days === 365 ? 'in the last year' : `in the last ${String(days)} days`;
|
|
185
|
+
// "6% cash back in your first year, through Mar 3, 2027; 3% cash back after
|
|
186
|
+
// that."
|
|
187
|
+
export const introRateText = (earn, standard, days, until) => `${earn} in your ${introPeriod(days)}, through ${shortDate(until)}; ${standard} after that.`;
|
|
188
|
+
// For a card whose opening date isn't known: it's never assumed to be new.
|
|
189
|
+
export const introUnknownText = (name, earn, standard, days) => `A ${introAdjective(days)} rate may apply on ${name}: ${earn} here in an account's ${introPeriod(days)}, instead of the ${standard} it's ranked at. Give the card's opening date if it opened ${introSince(days)}.`;
|
|
190
|
+
export const introEndsText = (name, days, until) => `${name}'s ${introAdjective(days)} rate ends after ${shortDate(until)}; from then on it earns its standard rates.`;
|
|
191
|
+
// Merchant statement credits. Always dollars, never a percentage.
|
|
192
|
+
export const CREDIT_PERIODS = {
|
|
193
|
+
month: 'each month',
|
|
194
|
+
quarter: 'each quarter',
|
|
195
|
+
half_year: 'each half year',
|
|
196
|
+
calendar_year: 'each calendar year',
|
|
197
|
+
cardmember_year: 'each cardmember year',
|
|
198
|
+
};
|
|
199
|
+
export const CREDIT_THIS_PERIOD = {
|
|
200
|
+
month: 'this month',
|
|
201
|
+
quarter: 'this quarter',
|
|
202
|
+
half_year: 'this half year',
|
|
203
|
+
calendar_year: 'this year',
|
|
204
|
+
cardmember_year: 'this cardmember year',
|
|
205
|
+
};
|
|
206
|
+
export const ENROLL_FIRST = 'enroll first';
|
|
207
|
+
// The group of cards with a credit at the store, shown before the ranking.
|
|
208
|
+
export const CREDIT_GROUP = 'With a store credit';
|
|
209
|
+
// "$75 back each quarter"
|
|
210
|
+
export const creditBack = (c) => `${money(c.amount)} back ${CREDIT_PERIODS[c.period]}`;
|
|
211
|
+
// "$75 back each quarter, then 1x Amex Membership Rewards"
|
|
212
|
+
export const creditThen = (c, earn) => `${creditBack(c)}, then ${earn}`;
|
|
213
|
+
// "$35 left this quarter"
|
|
214
|
+
export const creditLeft = (left, period) => `${money(left)} left ${CREDIT_THIS_PERIOD[period]}`;
|
|
215
|
+
// A line for an answer that uses the card's credit, or says why it doesn't.
|
|
216
|
+
export function creditAnswerText(c, dollars) {
|
|
217
|
+
const credit = `${c.name} credit (${creditBack(c)})`;
|
|
218
|
+
if (!c.enrolled)
|
|
219
|
+
return `Has a ${credit} here; ${ENROLL_FIRST} with the issuer, then mark it enrolled in your wallet. It isn't counted until then.`;
|
|
220
|
+
if (c.left === null)
|
|
221
|
+
return `Has a ${credit} here; give the card's anniversary to track it.`;
|
|
222
|
+
if (c.left <= 0)
|
|
223
|
+
return `Its ${c.name} credit is used up ${CREDIT_THIS_PERIOD[c.period]}${c.until === null ? '' : `; it renews after ${shortDate(c.until)}`}.`;
|
|
224
|
+
if (dollars === null)
|
|
225
|
+
return `Its ${credit} has ${creditLeft(c.left, c.period)}; give the amount to count it.`;
|
|
226
|
+
return dollars < c.left
|
|
227
|
+
? `${money(dollars)} back from its ${credit}, on top of what it earns; ${creditLeft(roundCents(c.left - dollars), c.period)} after this.`
|
|
228
|
+
: `${money(dollars)} back from its ${credit}: all that's left ${CREDIT_THIS_PERIOD[c.period]}, on top of what it earns.`;
|
|
229
|
+
}
|
|
230
|
+
// One short line beside a ranked card: "+ $35 back from its Lululemon
|
|
231
|
+
// credit", "Lululemon credit: $35 left this quarter".
|
|
232
|
+
export function creditShort(c, dollars) {
|
|
233
|
+
if (!c.enrolled)
|
|
234
|
+
return `${c.name} credit: ${creditBack(c)}, ${ENROLL_FIRST}`;
|
|
235
|
+
if (c.left === null)
|
|
236
|
+
return `${c.name} credit: ${creditBack(c)}`;
|
|
237
|
+
if (c.left <= 0)
|
|
238
|
+
return `${c.name} credit used up ${CREDIT_THIS_PERIOD[c.period]}`;
|
|
239
|
+
if (dollars === null || dollars <= 0)
|
|
240
|
+
return `${c.name} credit: ${creditLeft(c.left, c.period)}`;
|
|
241
|
+
return `+ ${money(dollars)} back from its ${c.name} credit`;
|
|
242
|
+
}
|
|
243
|
+
// For the to-do list: "Use your $75 lululemon credit on Amex Platinum by Dec
|
|
244
|
+
// 31, 2026: $35 left."
|
|
245
|
+
export const useCreditText = (card, c, left) => `Use your ${money(c.amount)} ${c.name} credit on ${card}${c.until === null ? '' : ` by ${shortDate(c.until)}`}: ${money(left)} left.`;
|
|
246
|
+
export const enrollCreditText = (card, name) => `Enroll in ${card}'s ${name} credit with the issuer, then mark it enrolled.`;
|
|
247
|
+
// Simulations, the cheat sheet and welcome-offer comparisons.
|
|
248
|
+
export const CREDITS_LEFT_OUT = 'Merchant statement credits (dollars back at a named store) are left out.';
|