earnrate 0.2.3 → 0.2.5
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 +15 -14
- package/data/CHANGELOG.md +74 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-cash-everyday.yaml +5 -4
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +5 -1
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +5 -1
- package/data/cards/chase-freedom-flex.yaml +1 -1
- package/data/cards/chase-ihg-one-rewards-premier.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards.yaml +7 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier.yaml +4 -0
- package/data/cards/chase-southwest-priority.yaml +4 -0
- package/data/cards/chase-united-club.yaml +1 -1
- package/data/cards/citi-aadvantage-globe.yaml +7 -7
- package/data/cards/citi-costco-anywhere-visa-business.yaml +9 -1
- package/data/cards/citi-costco-anywhere-visa.yaml +9 -1
- package/data/cards/td-bank-nordstrom-credit-card.yaml +4 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +4 -0
- package/data/categories.yaml +12 -0
- package/data/currencies.yaml +2 -0
- package/data/memberships.yaml +33 -0
- package/data/merchants/overrides.yaml +33 -0
- package/data/merchants/seed.yaml +0 -6
- package/dist/cli/cheatsheet.js +3 -3
- package/dist/cli/explore.js +12 -11
- package/dist/cli/import.js +13 -12
- package/dist/cli/offers.js +2 -1
- package/dist/cli/run.js +18 -4
- package/dist/cli/wallet.js +42 -0
- package/dist/data/file-store.js +5 -2
- package/dist/data/integrity.js +26 -0
- package/dist/data/load.js +10 -2
- package/dist/data/store.js +8 -1
- package/dist/data/wallet-edit.js +83 -2
- package/dist/engine/cheat-sheet.js +53 -14
- package/dist/engine/compare.js +8 -0
- package/dist/engine/explore.js +18 -20
- package/dist/engine/memberships.js +71 -0
- package/dist/engine/offers.js +37 -5
- package/dist/engine/profiles.js +8 -0
- package/dist/engine/recommend.js +78 -44
- package/dist/engine/setup.js +22 -5
- package/dist/engine/simulate.js +36 -14
- package/dist/engine/text.js +67 -2
- package/dist/engine/valuation.js +1 -1
- package/dist/engine/wallet-change.js +42 -4
- package/dist/engine/wallet-summary.js +83 -37
- package/dist/format/card-text.js +19 -29
- package/dist/format/cards.js +8 -9
- package/dist/format/explore.js +22 -14
- package/dist/format/offers.js +12 -21
- package/dist/format/recommendation.js +16 -10
- package/dist/format/wallet-yaml.js +18 -3
- package/dist/format/wallet.js +16 -20
- package/dist/mcp/context.js +31 -4
- package/dist/mcp/inputs.js +17 -8
- package/dist/mcp/schemas.js +21 -0
- package/dist/mcp/server.js +1 -0
- package/dist/mcp/tools/account.js +28 -10
- package/dist/mcp/tools/answers.js +51 -18
- package/dist/mcp/tools/wallet.js +106 -11
- package/dist/schema/card.js +7 -0
- package/dist/schema/category.js +4 -0
- package/dist/schema/currency.js +2 -0
- package/dist/schema/membership.js +15 -0
- package/dist/schema/merchant.js +4 -0
- package/dist/schema/wallet.js +4 -0
- package/package.json +1 -1
- package/wallet.example.yaml +1 -0
package/dist/engine/recommend.js
CHANGED
|
@@ -3,23 +3,13 @@ import { bonusStatus } from "./bonus.js";
|
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { daysBetween } from "./periods.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
-
import {
|
|
6
|
+
import { EPSILON, isClose } from "./compare.js";
|
|
7
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
7
8
|
import { cardValuation } from "./valuation.js";
|
|
8
|
-
const EPSILON = 1e-9;
|
|
9
9
|
export const STALE_AFTER_DAYS = 180;
|
|
10
|
+
// The warning when no card works; the CLI shows it as the answer instead.
|
|
11
|
+
export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
|
|
10
12
|
const NEAR_CAP_SHARE = 0.2;
|
|
11
|
-
const NETWORK_NAMES = {
|
|
12
|
-
visa: 'Visa',
|
|
13
|
-
mastercard: 'Mastercard',
|
|
14
|
-
amex: 'American Express',
|
|
15
|
-
discover: 'Discover',
|
|
16
|
-
};
|
|
17
|
-
const PERIOD_WORDS = {
|
|
18
|
-
month: 'monthly',
|
|
19
|
-
quarter: 'quarterly',
|
|
20
|
-
calendar_year: 'calendar-year',
|
|
21
|
-
cardmember_year: 'cardmember-year',
|
|
22
|
-
};
|
|
23
13
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
24
14
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
25
15
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
@@ -123,7 +113,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
123
113
|
const { best, nominal } = evaluation;
|
|
124
114
|
const warnings = [];
|
|
125
115
|
const capLabel = (e) => e.cap
|
|
126
|
-
? `${money(e.cap.cap.amount)} ${
|
|
116
|
+
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
127
117
|
: '';
|
|
128
118
|
if (best.cap && best.rule) {
|
|
129
119
|
const { remaining, cap, period } = best.cap;
|
|
@@ -154,7 +144,7 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
154
144
|
warnings.push(`Your chosen categories are set for ${evaluation.staleChoice}, not the current period, so chosen-category bonuses don't apply until you update them.`);
|
|
155
145
|
}
|
|
156
146
|
for (const rule of evaluation.needsActivation) {
|
|
157
|
-
warnings.push(`Activate
|
|
147
|
+
warnings.push(`Activate this bonus with the issuer (it's free) to earn ${earnText(catalog, rule.rate, valuation.currency)} here${rule.valid_to === undefined ? '' : ` through ${shortDate(rule.valid_to)}`}.`);
|
|
158
148
|
}
|
|
159
149
|
return warnings;
|
|
160
150
|
}
|
|
@@ -264,7 +254,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
264
254
|
const warnings = [];
|
|
265
255
|
const notes = [];
|
|
266
256
|
if (merchant.source === 'none') {
|
|
267
|
-
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply.
|
|
257
|
+
warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. If you know what kind of business it is, give its category (like dining or grocery) to find bonus rates.`);
|
|
268
258
|
}
|
|
269
259
|
if (merchant.source === 'caller') {
|
|
270
260
|
warnings.push(`"${merchant.input}" isn't in earnrate's merchant list, so it uses the category given with the request. Check how it codes on your statement.`);
|
|
@@ -279,16 +269,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
279
269
|
const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
|
|
280
270
|
warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
|
|
281
271
|
for (const category of merchant.ambiguity.mayCodeAs) {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
if (alt
|
|
285
|
-
|
|
286
|
-
}
|
|
272
|
+
const alts = rankEntries(catalog, wallet, purchase, merchant, category).entries;
|
|
273
|
+
const alt = alts[0];
|
|
274
|
+
if (!alt || !winner)
|
|
275
|
+
continue;
|
|
276
|
+
const as = `If it codes as ${categoryPhrase(catalog, category)}`;
|
|
277
|
+
// The same pick there may share the top with another card.
|
|
278
|
+
const twin = alts.find((e) => e.card.id !== winner.card.id &&
|
|
279
|
+
alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
|
|
280
|
+
if (alt.card.id !== winner.card.id)
|
|
281
|
+
warnings.push(`${as}, ${displayName(alt)} wins (${num(alt.effectiveReturnPct)}%).`);
|
|
282
|
+
else if (twin)
|
|
283
|
+
warnings.push(`${as}, ${displayName(twin)} earns as much (${num(twin.effectiveReturnPct)}%).`);
|
|
287
284
|
}
|
|
288
285
|
}
|
|
289
286
|
if (!winner) {
|
|
290
|
-
warnings.push(
|
|
291
|
-
|
|
287
|
+
warnings.push(noCardText(merchant.resolvedName));
|
|
288
|
+
// Only where the merchant's data says what its website takes: without
|
|
289
|
+
// that, every network is assumed, and a gas pump has no website.
|
|
290
|
+
const record = [
|
|
291
|
+
...catalog.merchants.overrides,
|
|
292
|
+
...catalog.merchants.seed,
|
|
293
|
+
].find((m) => m.id === merchant.merchantId);
|
|
294
|
+
const online = merchant.channel === 'in_store' &&
|
|
295
|
+
merchant.merchantId !== null &&
|
|
296
|
+
record?.online?.accepts !== undefined
|
|
292
297
|
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
293
298
|
: null;
|
|
294
299
|
const alt = online
|
|
@@ -313,9 +318,6 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
313
318
|
warnings.push(`${name}'s rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them. If this purchase is in that period's categories, ${name} may earn more. An earnrate update adds them once the issuer announces them.`);
|
|
314
319
|
}
|
|
315
320
|
}
|
|
316
|
-
const tie = tieNote(entries);
|
|
317
|
-
if (tie)
|
|
318
|
-
notes.push(tie);
|
|
319
321
|
for (const id of merchant.bookedVia) {
|
|
320
322
|
const via = resolveMerchantById(catalog, id, purchase.channel);
|
|
321
323
|
if (!via || !winner)
|
|
@@ -326,19 +328,37 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
326
328
|
notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${num(alt.effectiveReturnPct)}%.`);
|
|
327
329
|
}
|
|
328
330
|
}
|
|
329
|
-
const
|
|
331
|
+
const open = bonusPick(catalog, wallet, entries, purchase.date);
|
|
332
|
+
// Behind pace, the bonus card takes the pick; on pace, only where it earns
|
|
333
|
+
// as much as the best card (it costs nothing there). Within a point, it's
|
|
334
|
+
// mentioned; otherwise the bonus isn't this purchase's business.
|
|
335
|
+
const gap = open && winner
|
|
336
|
+
? winner.effectiveReturnPct - open.entry.effectiveReturnPct
|
|
337
|
+
: Infinity;
|
|
338
|
+
// Already the best card, it's the pick anyway: a tie needs no note.
|
|
339
|
+
const isBest = open !== null && open.entry === winner;
|
|
340
|
+
const ties = !isBest && gap <= EPSILON;
|
|
341
|
+
const bonus = open && (open.behind || ties) ? open : null;
|
|
342
|
+
const best = winner?.effectiveReturnPct ?? 0;
|
|
343
|
+
if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
|
|
344
|
+
notes.push(`${displayName(open.entry)} earns ${num(open.entry.effectiveReturnPct)}% here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${num(best)}%, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
|
|
345
|
+
}
|
|
330
346
|
const chosen = bonus?.entry ?? winner;
|
|
347
|
+
// A tie among the top cards doesn't matter when a welcome bonus decides.
|
|
348
|
+
const tie = bonus ? null : tieNote(entries);
|
|
349
|
+
if (tie)
|
|
350
|
+
notes.push(tie);
|
|
331
351
|
const top = ranked.find((r) => r.cardId === chosen?.card.id);
|
|
332
|
-
const
|
|
352
|
+
const first = ranked[0];
|
|
333
353
|
let bonusResult = null;
|
|
334
|
-
if (bonus && top &&
|
|
354
|
+
if (bonus && top && first) {
|
|
335
355
|
const { status } = bonus;
|
|
336
356
|
const remaining = status.remaining ?? 0;
|
|
337
357
|
const deadline = status.deadline ?? purchase.date;
|
|
338
358
|
const daysLeft = status.daysLeft ?? 0;
|
|
339
|
-
const costHere =
|
|
359
|
+
const costHere = ties || top.earned === null || first.earned === null
|
|
340
360
|
? null
|
|
341
|
-
:
|
|
361
|
+
: roundCents(first.earned - top.earned);
|
|
342
362
|
bonusResult = {
|
|
343
363
|
cardId: top.cardId,
|
|
344
364
|
checkoutLabel: top.checkoutLabel,
|
|
@@ -349,16 +369,16 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
349
369
|
costHere,
|
|
350
370
|
};
|
|
351
371
|
const pace = daysLeft >= 7
|
|
352
|
-
? ` (about ${
|
|
372
|
+
? ` (about ${wholeDollars(remaining / (daysLeft / 7))} a week)`
|
|
353
373
|
: '';
|
|
354
|
-
const worth = status.value === null
|
|
355
|
-
|
|
356
|
-
:
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
const worth = status.value === null ? '' : `, worth about ${wholeDollars(status.value)}`;
|
|
375
|
+
notes.unshift(ties
|
|
376
|
+
? `${displayName(bonus.entry)} earns as much here as any of your cards, so it's the pick: it helps its welcome bonus, ${money(remaining)} more to spend by ${deadline}${worth}.`
|
|
377
|
+
: `You're behind pace on ${displayName(bonus.entry)}'s welcome bonus: ${money(remaining)} more to spend by ${deadline}${pace}${worth}.${isBest
|
|
378
|
+
? ''
|
|
379
|
+
: costHere === null
|
|
380
|
+
? ` It earns ${num(top.effectiveReturnPct)}% here against ${num(first.effectiveReturnPct)}% on ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`
|
|
381
|
+
: ` Using it here earns ${money(costHere)} less than ${displayName(entries[0] ?? bonus.entry)}, which the bonus outweighs.`}`);
|
|
362
382
|
}
|
|
363
383
|
let confidence = merchant.confidence;
|
|
364
384
|
if (confidence === 'high' && chosen && top) {
|
|
@@ -386,17 +406,31 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
386
406
|
bonus: bonusResult,
|
|
387
407
|
};
|
|
388
408
|
}
|
|
409
|
+
// Behind a steady pace: less spent than an even share of the spending needed
|
|
410
|
+
// for the days gone by. Ahead of it, the bonus will likely be met anyway, so
|
|
411
|
+
// it doesn't justify a card that earns less.
|
|
412
|
+
function behindPace(status, date) {
|
|
413
|
+
if (status.spend === null || status.deadline === null)
|
|
414
|
+
return false;
|
|
415
|
+
const window = Math.max(1, daysBetween(status.opened, status.deadline));
|
|
416
|
+
const elapsed = Math.min(window, daysBetween(status.opened, date));
|
|
417
|
+
return status.spent < (status.spend * elapsed) / window;
|
|
418
|
+
}
|
|
389
419
|
// The open welcome bonus that most needs this purchase: the one with the most
|
|
390
|
-
// spend still needed per day left.
|
|
420
|
+
// spend still needed per day left. Only a bonus behind pace takes the pick
|
|
421
|
+
// (`behind`); one on pace is only mentioned.
|
|
391
422
|
function bonusPick(catalog, wallet, entries, date) {
|
|
392
423
|
let pick = null;
|
|
393
424
|
for (const entry of entries) {
|
|
394
425
|
const status = bonusStatus(catalog, wallet, entry.held, date);
|
|
395
426
|
if (status?.state !== 'open' || date < status.opened)
|
|
396
427
|
continue;
|
|
428
|
+
const behind = behindPace(status, date);
|
|
397
429
|
const pace = (status.remaining ?? 0) / Math.max(1, status.daysLeft ?? 1);
|
|
398
|
-
if (!pick ||
|
|
399
|
-
|
|
430
|
+
if (!pick ||
|
|
431
|
+
(behind && !pick.behind) ||
|
|
432
|
+
(behind === pick.behind && pace > pick.pace))
|
|
433
|
+
pick = { entry, status, behind, pace };
|
|
400
434
|
}
|
|
401
435
|
return pick;
|
|
402
436
|
}
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
2
3
|
import { daysBetween } from "./periods.js";
|
|
3
4
|
import { rotationGap } from "./rotation.js";
|
|
4
5
|
// Rules that start within this many days are worth activating now.
|
|
@@ -45,7 +46,7 @@ function matcher(card) {
|
|
|
45
46
|
(token.length >= 5 &&
|
|
46
47
|
haystack.some((w) => editDistance(w, token) <= (token.length >= 8 ? 2 : 1)));
|
|
47
48
|
}
|
|
48
|
-
function needsAnniversary(card) {
|
|
49
|
+
export function needsAnniversary(card) {
|
|
49
50
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
50
51
|
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
|
|
51
52
|
}
|
|
@@ -62,10 +63,18 @@ export function membershipsUsed(card) {
|
|
|
62
63
|
...new Set(card.rules.flatMap((r) => r.requires?.membership === undefined ? [] : [r.requires.membership])),
|
|
63
64
|
];
|
|
64
65
|
}
|
|
66
|
+
// Memberships that matter to a card: ones its rates need, and one the card
|
|
67
|
+
// itself needs (a Costco membership for Costco Anywhere).
|
|
68
|
+
export function membershipsFor(card) {
|
|
69
|
+
return [
|
|
70
|
+
...membershipsUsed(card),
|
|
71
|
+
...(card.requires_membership ? [card.requires_membership] : []),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
65
74
|
export function knownMemberships(catalog) {
|
|
66
|
-
return
|
|
75
|
+
return catalog.memberships.map((m) => m.id).sort();
|
|
67
76
|
}
|
|
68
|
-
export function cardInfo(card, wallet, today) {
|
|
77
|
+
export function cardInfo(catalog, card, wallet, today) {
|
|
69
78
|
return {
|
|
70
79
|
id: card.id,
|
|
71
80
|
name: card.name,
|
|
@@ -84,6 +93,13 @@ export function cardInfo(card, wallet, today) {
|
|
|
84
93
|
default: c.default ?? null,
|
|
85
94
|
})),
|
|
86
95
|
memberships: membershipsUsed(card),
|
|
96
|
+
requiresMembership: card.requires_membership === undefined
|
|
97
|
+
? null
|
|
98
|
+
: {
|
|
99
|
+
id: card.requires_membership,
|
|
100
|
+
name: membershipName(catalog, card.requires_membership),
|
|
101
|
+
annualFee: membership(catalog, card.requires_membership)?.annual_fee ?? 0,
|
|
102
|
+
},
|
|
87
103
|
activation: activationRules(card, today),
|
|
88
104
|
needsAnniversary: needsAnniversary(card),
|
|
89
105
|
};
|
|
@@ -111,7 +127,7 @@ export function searchCards(catalog, query, wallet, today) {
|
|
|
111
127
|
.sort(byShortestId)
|
|
112
128
|
.slice(0, 5);
|
|
113
129
|
return {
|
|
114
|
-
cards: chosen.map((s) => cardInfo(s.card, wallet, today)),
|
|
130
|
+
cards: chosen.map((s) => cardInfo(catalog, s.card, wallet, today)),
|
|
115
131
|
exact,
|
|
116
132
|
};
|
|
117
133
|
}
|
|
@@ -137,7 +153,8 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
137
153
|
: `${name} needs its ${choice.id} pick for this period (${String(choice.pick)} of: ${options}). Until then those categories earn the base rate.`,
|
|
138
154
|
});
|
|
139
155
|
}
|
|
140
|
-
|
|
156
|
+
// With one tier of a group saved, the others aren't missing.
|
|
157
|
+
const missing = membershipsUsed(card).filter((m) => !wallet.memberships.some((held) => held === m || sameGroup(catalog, held, m)));
|
|
141
158
|
if (missing.length > 0) {
|
|
142
159
|
notes.push({
|
|
143
160
|
kind: 'membership',
|
package/dist/engine/simulate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { resolveMerchant } from "../merchants/resolve.js";
|
|
2
2
|
import { evaluateCard, purchaseContext, usableAt } from "./evaluate.js";
|
|
3
3
|
import { categoryResolution, findCategory, idealHolding, matchesFilters, } from "./explore.js";
|
|
4
|
+
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
4
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
5
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
6
7
|
import { rotationGap } from "./rotation.js";
|
|
7
|
-
import { roundCents } from "./text.js";
|
|
8
|
+
import { money, roundCents } from "./text.js";
|
|
8
9
|
import { cardValuation } from "./valuation.js";
|
|
9
|
-
|
|
10
|
+
import { EPSILON } from "./compare.js";
|
|
10
11
|
const MONTHS = 12;
|
|
11
12
|
const SHORTLIST = 8; // best single cards considered for pairs
|
|
12
13
|
const PER_LINE = 2; // plus the best cards for each spending line
|
|
@@ -44,7 +45,10 @@ function withCurrentPicks(card, held, date) {
|
|
|
44
45
|
// Spends `lines` every month for a year. Each dollar goes to the card that
|
|
45
46
|
// earns the most on it right then, so spend moves to another card once a
|
|
46
47
|
// cap runs out.
|
|
47
|
-
export function simulateWallet(catalog, wallet, lines, start
|
|
48
|
+
export function simulateWallet(catalog, wallet, lines, start,
|
|
49
|
+
// Cards the person already has: their first year is behind them and any
|
|
50
|
+
// membership they need is paid for.
|
|
51
|
+
held = []) {
|
|
48
52
|
const cards = wallet.cards.map((held) => {
|
|
49
53
|
const card = catalog.cards.find((c) => c.id === held.id);
|
|
50
54
|
if (!card)
|
|
@@ -120,7 +124,16 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
120
124
|
});
|
|
121
125
|
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0));
|
|
122
126
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
127
|
+
const paid = paidMemberships(catalog, {
|
|
128
|
+
memberships: wallet.memberships,
|
|
129
|
+
heldCards: held,
|
|
130
|
+
merchantIds: lines.map((l) => l.merchant.merchantId),
|
|
131
|
+
});
|
|
132
|
+
const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
|
|
133
|
+
const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
|
|
123
134
|
const notes = [];
|
|
135
|
+
for (const m of memberships)
|
|
136
|
+
notes.push(membershipNote(catalog, m));
|
|
124
137
|
const dates = monthDates(start);
|
|
125
138
|
for (const { card } of cards) {
|
|
126
139
|
const gaps = [
|
|
@@ -130,9 +143,10 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
130
143
|
notes.push(`${card.name}'s rotating categories for ${gaps.join(', ')} aren't in earnrate's data yet, so those quarters count at its everyday rates.`);
|
|
131
144
|
if (card.annual_fee === undefined)
|
|
132
145
|
notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
|
|
133
|
-
else if (card.
|
|
146
|
+
else if (!held.includes(card.id) &&
|
|
147
|
+
card.first_year_fee !== undefined &&
|
|
134
148
|
card.first_year_fee !== card.annual_fee)
|
|
135
|
-
notes.push(`${card.name}'s fee is
|
|
149
|
+
notes.push(`${card.name}'s fee is ${money(card.first_year_fee)} the first year; the yearly figure uses the ${money(card.annual_fee)} that follows.`);
|
|
136
150
|
}
|
|
137
151
|
return {
|
|
138
152
|
cards: wallet.cards.map((h) => ({
|
|
@@ -142,7 +156,9 @@ export function simulateWallet(catalog, wallet, lines, start) {
|
|
|
142
156
|
spend: roundCents(lineResults.reduce((sum, l) => sum + l.spend, 0)),
|
|
143
157
|
rewards,
|
|
144
158
|
annualFees,
|
|
145
|
-
|
|
159
|
+
memberships,
|
|
160
|
+
membershipFees,
|
|
161
|
+
net: roundCents(rewards - annualFees - membershipFees),
|
|
146
162
|
lines: lineResults,
|
|
147
163
|
notes,
|
|
148
164
|
};
|
|
@@ -192,7 +208,7 @@ function walletWith(base, card, picks, options) {
|
|
|
192
208
|
function bestSimulation(catalog, base, card, lines, options) {
|
|
193
209
|
let best = null;
|
|
194
210
|
for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
|
|
195
|
-
const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
|
|
211
|
+
const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start, options.held);
|
|
196
212
|
if (!best || sim.net > best.net + EPSILON)
|
|
197
213
|
best = sim;
|
|
198
214
|
}
|
|
@@ -200,7 +216,8 @@ function bestSimulation(catalog, base, card, lines, options) {
|
|
|
200
216
|
throw new Error(`no simulation for ${card.id}`);
|
|
201
217
|
return best;
|
|
202
218
|
}
|
|
203
|
-
const
|
|
219
|
+
const costs = (s) => s.annualFees + s.membershipFees;
|
|
220
|
+
const byNet = (a, b) => b.net - a.net || costs(a) - costs(b);
|
|
204
221
|
// Pairs that tie and share their first card differ only in an
|
|
205
222
|
// interchangeable second card (several flat 2% cards), so they fold into one.
|
|
206
223
|
function foldTies(pairs) {
|
|
@@ -208,7 +225,7 @@ function foldTies(pairs) {
|
|
|
208
225
|
for (const pair of pairs) {
|
|
209
226
|
const [a, b] = pair.cards;
|
|
210
227
|
const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
|
|
211
|
-
f
|
|
228
|
+
costs(f) === costs(pair) &&
|
|
212
229
|
f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
|
|
213
230
|
if (!twin) {
|
|
214
231
|
folded.push({ ...pair, alternatives: [] });
|
|
@@ -275,8 +292,9 @@ function projectedWallet(wallet, catalog, start) {
|
|
|
275
292
|
}
|
|
276
293
|
export function simulateAdditions(catalog, wallet, lines, options) {
|
|
277
294
|
const projected = projectedWallet(wallet, catalog, options.start);
|
|
278
|
-
const
|
|
279
|
-
const
|
|
295
|
+
const heldIds = wallet.cards.map((c) => c.id);
|
|
296
|
+
const current = simulateWallet(catalog, projected, lines, options.start, heldIds);
|
|
297
|
+
const held = new Set(heldIds);
|
|
280
298
|
const additions = [];
|
|
281
299
|
for (const card of catalog.cards) {
|
|
282
300
|
if (held.has(card.id) || !matchesFilters(card, options.filters))
|
|
@@ -285,19 +303,23 @@ export function simulateAdditions(catalog, wallet, lines, options) {
|
|
|
285
303
|
...options,
|
|
286
304
|
memberships: wallet.memberships,
|
|
287
305
|
valuations: wallet.valuations,
|
|
306
|
+
held: heldIds,
|
|
288
307
|
});
|
|
289
308
|
const extraRewards = roundCents(simulation.rewards - current.rewards);
|
|
290
309
|
const annualFee = card.annual_fee ?? 0;
|
|
310
|
+
const membershipFee = roundCents(simulation.membershipFees - current.membershipFees);
|
|
291
311
|
additions.push({
|
|
292
312
|
cardId: card.id,
|
|
293
313
|
picks: simulation.cards.at(-1)?.picks ?? {},
|
|
294
314
|
extraRewards,
|
|
295
315
|
annualFee,
|
|
296
|
-
|
|
316
|
+
membershipFee,
|
|
317
|
+
gain: roundCents(extraRewards - annualFee - membershipFee),
|
|
297
318
|
simulation,
|
|
298
319
|
});
|
|
299
320
|
}
|
|
300
|
-
additions.sort((a, b) => b.gain - a.gain ||
|
|
321
|
+
additions.sort((a, b) => b.gain - a.gain ||
|
|
322
|
+
a.annualFee + a.membershipFee - (b.annualFee + b.membershipFee));
|
|
301
323
|
return { current, additions };
|
|
302
324
|
}
|
|
303
325
|
// Rough monthly amounts for a U.S. household: the starting point when someone
|
|
@@ -320,4 +342,4 @@ export function spendLineFor(catalog, what, monthly) {
|
|
|
320
342
|
export function typicalSpendLines(catalog) {
|
|
321
343
|
return resolveSpendLines(catalog, TYPICAL_MONTHLY_SPEND.map((t) => spendLineFor(catalog, t.what, t.monthly)));
|
|
322
344
|
}
|
|
323
|
-
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what}
|
|
345
|
+
export const TYPICAL_SPEND_NOTE = `No spending was given, so this uses rough typical monthly amounts for a U.S. household: ${TYPICAL_MONTHLY_SPEND.map((t) => `${t.what} ${money(t.monthly)}`).join(', ')}. Give your own amounts for a closer answer.`;
|
package/dist/engine/text.js
CHANGED
|
@@ -2,8 +2,24 @@ import { singleMcc } from "./evaluate.js";
|
|
|
2
2
|
export const num = (n) => String(Number(n.toFixed(2)));
|
|
3
3
|
export const pct = (n) => `${num(n)}%`;
|
|
4
4
|
export const roundCents = (n) => Math.round(n * 100) / 100;
|
|
5
|
-
//
|
|
6
|
-
export const
|
|
5
|
+
// An id in words: "gas-ev-charging" reads "gas ev charging".
|
|
6
|
+
export const words = (id) => id.replace(/[-_.]/g, ' ');
|
|
7
|
+
const dateWords = (date, options) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
8
|
+
...options,
|
|
9
|
+
timeZone: 'UTC',
|
|
10
|
+
});
|
|
11
|
+
// From YYYY-MM-DD, the same in every time zone: "Dec 31, 2026",
|
|
12
|
+
// "December 31, 2026", "December 2026".
|
|
13
|
+
export const shortDate = (date) => dateWords(date, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
14
|
+
export const longDate = (date) => dateWords(date, { month: 'long', day: 'numeric', year: 'numeric' });
|
|
15
|
+
export const monthYear = (date) => dateWords(date, { month: 'long', year: 'numeric' });
|
|
16
|
+
// Always cents ($4.00), for what one purchase earns. A loss reads −$4.00.
|
|
17
|
+
export const dollarsAndCents = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
18
|
+
// Rounded to whole dollars ($2,800), for fees, caps and yearly totals.
|
|
19
|
+
export const wholeDollars = (n) => money(Math.round(n));
|
|
20
|
+
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
|
|
21
|
+
// loss reads −$618.
|
|
22
|
+
export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
7
23
|
export function feeText(fees) {
|
|
8
24
|
const { annualFee, firstYearFee } = fees;
|
|
9
25
|
if (annualFee === null)
|
|
@@ -51,3 +67,52 @@ export function pointValuesText(catalog, shown) {
|
|
|
51
67
|
const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
|
|
52
68
|
return `Values points at: ${list.join(', ')}.`;
|
|
53
69
|
}
|
|
70
|
+
export const NETWORK_NAMES = {
|
|
71
|
+
visa: 'Visa',
|
|
72
|
+
mastercard: 'Mastercard',
|
|
73
|
+
amex: 'American Express',
|
|
74
|
+
discover: 'Discover',
|
|
75
|
+
store: 'store card',
|
|
76
|
+
};
|
|
77
|
+
// A name with its own "and" ("drugstores and pharmacies") would run into the
|
|
78
|
+
// list's: those lists use commas only, with those names last.
|
|
79
|
+
export const listed = (parts, joiner = 'and') => {
|
|
80
|
+
const anded = parts.filter((p) => p.includes(' and '));
|
|
81
|
+
if (anded.length > 0)
|
|
82
|
+
return [...parts.filter((p) => !p.includes(' and ')), ...anded].join(', ');
|
|
83
|
+
return parts.length <= 2
|
|
84
|
+
? parts.join(` ${joiner} `)
|
|
85
|
+
: `${parts.slice(0, -1).join(', ')} ${joiner} ${parts.at(-1) ?? ''}`;
|
|
86
|
+
};
|
|
87
|
+
const NUMBERS = {
|
|
88
|
+
one: '1',
|
|
89
|
+
two: '2',
|
|
90
|
+
three: '3',
|
|
91
|
+
four: '4',
|
|
92
|
+
five: '5',
|
|
93
|
+
six: '6',
|
|
94
|
+
};
|
|
95
|
+
// "five-percent" reads "5%"; other ids read as words.
|
|
96
|
+
export function choiceName(id) {
|
|
97
|
+
return words(id.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`));
|
|
98
|
+
}
|
|
99
|
+
// Period words in one place: a cap of $1,500 "a quarter", a "quarterly" cap,
|
|
100
|
+
// a category picked "each quarter".
|
|
101
|
+
export const CAP_PERIODS = {
|
|
102
|
+
month: 'a month',
|
|
103
|
+
quarter: 'a quarter',
|
|
104
|
+
calendar_year: 'a calendar year',
|
|
105
|
+
cardmember_year: 'a cardmember year',
|
|
106
|
+
};
|
|
107
|
+
export const CAP_ADJECTIVES = {
|
|
108
|
+
month: 'monthly',
|
|
109
|
+
quarter: 'quarterly',
|
|
110
|
+
calendar_year: 'calendar-year',
|
|
111
|
+
cardmember_year: 'cardmember-year',
|
|
112
|
+
};
|
|
113
|
+
export const PICK_PERIODS = {
|
|
114
|
+
month: 'each month',
|
|
115
|
+
quarter: 'each quarter',
|
|
116
|
+
calendar_year: 'each year',
|
|
117
|
+
until_changed: 'until you change it',
|
|
118
|
+
};
|
package/dist/engine/valuation.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { WALLET_VERSION, } from "../schema/wallet.js";
|
|
2
|
+
import { addMonths } from "./bonus.js";
|
|
1
3
|
import { capByKey } from "./evaluate.js";
|
|
2
4
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
3
5
|
import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
|
|
@@ -132,11 +134,25 @@ export function cardFields(card, held, input, today) {
|
|
|
132
134
|
problems.push(`"${b.by}" isn't a deadline after the opening date.`);
|
|
133
135
|
if (!card.welcome_offer && (b.spend === undefined || b.by === undefined))
|
|
134
136
|
problems.push(`earnrate has no published welcome offer for ${card.id}, so give the spending needed and the deadline.`);
|
|
137
|
+
// The terms are saved with the bonus, so a later change to the published
|
|
138
|
+
// offer, or a wallet exported and imported again, keeps the user's own.
|
|
139
|
+
const offer = card.welcome_offer;
|
|
140
|
+
const before = held?.bonus?.opened === b.opened ? held.bonus : undefined;
|
|
141
|
+
const spend = b.spend ?? before?.spend ?? offer?.spend;
|
|
142
|
+
const by = b.by ??
|
|
143
|
+
before?.by ??
|
|
144
|
+
(offer && isDate(b.opened)
|
|
145
|
+
? addMonths(b.opened, offer.months)
|
|
146
|
+
: undefined);
|
|
147
|
+
const points = before?.points ?? (before?.dollars ? undefined : offer?.points);
|
|
148
|
+
const dollars = before?.dollars ?? (points !== undefined ? undefined : offer?.dollars);
|
|
135
149
|
fields.bonus = {
|
|
136
150
|
opened: b.opened,
|
|
137
151
|
spent: b.spent ?? held?.bonus?.spent ?? 0,
|
|
138
|
-
...(
|
|
139
|
-
...(
|
|
152
|
+
...(spend === undefined ? {} : { spend }),
|
|
153
|
+
...(by === undefined ? {} : { by }),
|
|
154
|
+
...(points === undefined ? {} : { points }),
|
|
155
|
+
...(dollars === undefined ? {} : { dollars }),
|
|
140
156
|
};
|
|
141
157
|
}
|
|
142
158
|
return problems.length > 0 ? problems.join('\n') : fields;
|
|
@@ -173,7 +189,12 @@ export function addToWallet(catalog, wallet, input, today) {
|
|
|
173
189
|
const held = withFields(newHolding(card.id), fields);
|
|
174
190
|
const next = wallet
|
|
175
191
|
? { ...wallet, cards: [...wallet.cards, held] }
|
|
176
|
-
: {
|
|
192
|
+
: {
|
|
193
|
+
version: WALLET_VERSION,
|
|
194
|
+
valuations: {},
|
|
195
|
+
memberships: [],
|
|
196
|
+
cards: [held],
|
|
197
|
+
};
|
|
177
198
|
return {
|
|
178
199
|
ok: true,
|
|
179
200
|
wallet: next,
|
|
@@ -221,7 +242,7 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
221
242
|
const known = knownMemberships(catalog);
|
|
222
243
|
const unknown = memberships.filter((m) => !known.includes(m));
|
|
223
244
|
if (unknown.length > 0) {
|
|
224
|
-
return failed(`No card
|
|
245
|
+
return failed(`No card uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
|
|
225
246
|
}
|
|
226
247
|
return {
|
|
227
248
|
ok: true,
|
|
@@ -230,3 +251,20 @@ export function withMemberships(catalog, wallet, memberships) {
|
|
|
230
251
|
notes: [],
|
|
231
252
|
};
|
|
232
253
|
}
|
|
254
|
+
// Point values in cents per point, by currency id; null goes back to
|
|
255
|
+
// earnrate's default for that currency.
|
|
256
|
+
export function withValuations(catalog, wallet, changes) {
|
|
257
|
+
const unknown = Object.keys(changes).filter((id) => !catalog.currencies.some((c) => c.id === id));
|
|
258
|
+
if (unknown.length > 0)
|
|
259
|
+
return failed(`Unknown currency ${unknown.map((id) => `'${id}'`).join(', ')}. Currencies: ${list(catalog.currencies.map((c) => c.id))}.`);
|
|
260
|
+
const odd = Object.entries(changes).filter(([, cents]) => cents !== null && !(cents > 0 && cents <= MAX_CENTS));
|
|
261
|
+
if (odd.length > 0)
|
|
262
|
+
return failed(`A point is worth more than 0¢ and at most ${String(MAX_CENTS)}¢; check ${odd.map(([id]) => id).join(', ')}.`);
|
|
263
|
+
const valuations = Object.fromEntries([
|
|
264
|
+
...Object.entries(wallet.valuations).filter(([id]) => !(id in changes)),
|
|
265
|
+
...Object.entries(changes).filter(([, cents]) => cents !== null),
|
|
266
|
+
]);
|
|
267
|
+
return { ok: true, wallet: { ...wallet, valuations }, fields: {}, notes: [] };
|
|
268
|
+
}
|
|
269
|
+
// Higher than any program's points are worth, even transferred.
|
|
270
|
+
const MAX_CENTS = 5;
|