earnrate 0.1.2 → 0.1.4

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.
@@ -0,0 +1,292 @@
1
+ import { resolveMerchant } from "../merchants/resolve.js";
2
+ import { evaluateCard, purchaseContext } from "./evaluate.js";
3
+ import { periodKey } from "./periods.js";
4
+ import { categoryPhrase, currencyLabel, dollars, earnText, num, recommend, } from "./recommend.js";
5
+ import { rotationGap } from "./rotation.js";
6
+ import { membershipsUsed } from "./setup.js";
7
+ import { cardValuation } from "./valuation.js";
8
+ const EPSILON = 1e-9;
9
+ export const ALL_PERSONAL_CARDS = {
10
+ kind: 'any',
11
+ business: false,
12
+ noAnnualFee: false,
13
+ issuer: null,
14
+ includeClosed: false,
15
+ };
16
+ export function matchesFilters(card, filters) {
17
+ if (card.business !== filters.business)
18
+ return false;
19
+ if (!filters.includeClosed && card.applications_closed !== undefined)
20
+ return false;
21
+ if (filters.noAnnualFee && card.annual_fee !== 0)
22
+ return false;
23
+ if (filters.issuer !== null && card.issuer !== filters.issuer)
24
+ return false;
25
+ const isCash = card.currency === 'cash';
26
+ if (filters.kind === 'cash' && !isCash)
27
+ return false;
28
+ if (filters.kind === 'points' && isCash)
29
+ return false;
30
+ return true;
31
+ }
32
+ // The period a card's picks are keyed by in a wallet.
33
+ export function choicePeriod(card) {
34
+ for (const c of card.choices)
35
+ if (c.period !== 'until_changed')
36
+ return c.period;
37
+ return 'quarter';
38
+ }
39
+ // A card as its holder would set it up for the best result: the given picks,
40
+ // every activation done, and no cap used yet. The anniversary is `date`, so a
41
+ // cardmember-year cap starts fresh.
42
+ export function idealHolding(card, date, picks = {}) {
43
+ const period = choicePeriod(card);
44
+ return {
45
+ id: card.id,
46
+ anniversary: date.slice(5),
47
+ cap_usage: {},
48
+ activated: card.rules
49
+ .filter((r) => r.requires?.activation === true)
50
+ .map((r) => r.id),
51
+ ...(Object.keys(picks).length > 0
52
+ ? { chosen: { period: periodKey(period, date, null) ?? '', picks } }
53
+ : {}),
54
+ };
55
+ }
56
+ const PICK_PERIODS = {
57
+ month: 'set each month',
58
+ quarter: 'picked each quarter',
59
+ calendar_year: 'picked each year',
60
+ until_changed: 'until you change it',
61
+ };
62
+ const CAP_PERIODS = {
63
+ month: 'a month',
64
+ quarter: 'a quarter',
65
+ calendar_year: 'a calendar year',
66
+ cardmember_year: 'a cardmember year',
67
+ };
68
+ const plainWords = (s) => s
69
+ .toLowerCase()
70
+ .replace(/[^a-z0-9]+/g, ' ')
71
+ .trim();
72
+ // A category from everyday words: an alias ("groceries") first, then the
73
+ // name, then the id, so "grocery" means supermarkets rather than the parent.
74
+ export function findCategory(catalog, text) {
75
+ const wanted = plainWords(text);
76
+ if (wanted === '')
77
+ return null;
78
+ const by = (key) => catalog.categories.find((c) => key(c).map(plainWords).includes(wanted))?.id;
79
+ return (by((c) => c.aliases) ?? by((c) => [c.name]) ?? by((c) => [c.id]) ?? null);
80
+ }
81
+ export function categoryResolution(catalog, category, channel) {
82
+ const found = catalog.categories.find((c) => c.id === category);
83
+ if (!found)
84
+ throw new Error(`unknown category '${category}'`);
85
+ return {
86
+ input: found.name,
87
+ merchantId: null,
88
+ resolvedName: found.name,
89
+ category,
90
+ likelyMcc: null,
91
+ accepts: null,
92
+ ambiguity: null,
93
+ channel,
94
+ confidence: 'high',
95
+ source: 'caller',
96
+ bookedVia: [],
97
+ inference: null,
98
+ };
99
+ }
100
+ function walletOf(held, options) {
101
+ return {
102
+ valuations: options.valuations,
103
+ memberships: options.memberships,
104
+ cards: [held],
105
+ };
106
+ }
107
+ // One option per choice is enough to cover a single purchase, so each choice
108
+ // is tried option by option, keeping a pick only when it raises the rate.
109
+ function bestPicksForPurchase(catalog, card, merchant, purchase, options) {
110
+ const picks = {};
111
+ const rateWith = (p) => {
112
+ const held = idealHolding(card, purchase.date, p);
113
+ const ctx = purchaseContext(catalog, walletOf(held, options), merchant, merchant.category, purchase);
114
+ return evaluateCard(card, held, ctx).best.rate;
115
+ };
116
+ for (const choice of card.choices) {
117
+ let best = rateWith(picks);
118
+ for (const option of Object.keys(choice.options)) {
119
+ const trial = { ...picks, [choice.id]: [option] };
120
+ const rate = rateWith(trial);
121
+ if (rate > best + EPSILON) {
122
+ best = rate;
123
+ picks[choice.id] = [option];
124
+ }
125
+ }
126
+ }
127
+ return picks;
128
+ }
129
+ function requirementsFor(catalog, card, held, wallet, merchant, purchase) {
130
+ const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
131
+ const { best } = evaluateCard(card, held, ctx);
132
+ const rule = best.rule;
133
+ const none = { pick: null, activation: null, membership: null, cap: null };
134
+ if (!rule)
135
+ return none;
136
+ const choiceId = rule.match.choice;
137
+ const choice = card.choices.find((c) => c.id === choiceId);
138
+ const picked = choiceId === undefined ? undefined : held.chosen?.picks[choiceId];
139
+ const cap = best.cap?.cap;
140
+ return {
141
+ pick: choice && picked
142
+ ? { choice: choice.id, options: picked, period: choice.period }
143
+ : null,
144
+ activation: rule.requires?.activation === true ? rule.id : null,
145
+ membership: rule.requires?.membership ?? null,
146
+ cap: cap
147
+ ? {
148
+ amount: cap.amount,
149
+ period: cap.period,
150
+ thenRate: cap.then_rate,
151
+ currency: cardValuation(catalog, wallet, card).currency,
152
+ }
153
+ : null,
154
+ };
155
+ }
156
+ function conditionsText(catalog, r) {
157
+ const conditions = [];
158
+ if (r.pick) {
159
+ conditions.push(`Needs "${r.pick.options.join('", "')}" as its ${r.pick.choice} pick (${PICK_PERIODS[r.pick.period]}).`);
160
+ }
161
+ if (r.activation !== null)
162
+ conditions.push(`Needs activating with the issuer ("${r.activation}").`);
163
+ if (r.membership !== null)
164
+ conditions.push(`Needs ${r.membership}.`);
165
+ if (r.cap) {
166
+ conditions.push(`Up to ${dollars(r.cap.amount)} ${CAP_PERIODS[r.cap.period] ?? ''} at this rate, then ${earnText(catalog, r.cap.thenRate, r.cap.currency)}.`);
167
+ }
168
+ return conditions;
169
+ }
170
+ function membershipUpgrades(catalog, card, held, merchant, purchase, options, current) {
171
+ const upgrades = [];
172
+ for (const membership of membershipsUsed(card)) {
173
+ if (options.memberships.includes(membership))
174
+ continue;
175
+ const wallet = walletOf(held, {
176
+ ...options,
177
+ memberships: [...options.memberships, membership],
178
+ });
179
+ const ctx = purchaseContext(catalog, wallet, merchant, merchant.category, purchase);
180
+ const pct = evaluateCard(card, held, ctx).best.rate *
181
+ cardValuation(catalog, wallet, card).centsPerUnit;
182
+ if (pct > current + EPSILON)
183
+ upgrades.push({ membership, effectiveReturnPct: pct });
184
+ }
185
+ return upgrades.sort((a, b) => b.effectiveReturnPct - a.effectiveReturnPct);
186
+ }
187
+ function compareExplored(a, b) {
188
+ const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
189
+ if (Math.abs(byReturn) > EPSILON)
190
+ return byReturn;
191
+ const byFee = (a.annualFee ?? Infinity) - (b.annualFee ?? Infinity);
192
+ if (byFee !== 0)
193
+ return byFee;
194
+ const byConditions = a.conditions.length - b.conditions.length;
195
+ if (byConditions !== 0)
196
+ return byConditions;
197
+ return a.cardId.localeCompare(b.cardId);
198
+ }
199
+ // Ranks every card in the catalog that passes the filters for one purchase,
200
+ // each set up at its best: the pick that covers it, activated, cap unused.
201
+ export function exploreCards(catalog, query, options) {
202
+ const merchant = query.merchant !== null
203
+ ? resolveMerchant(catalog, query.merchant, query.channel)
204
+ : categoryResolution(catalog, query.category ?? 'other', query.channel);
205
+ const purchase = {
206
+ merchant: merchant.input,
207
+ amount: query.amount,
208
+ channel: query.channel,
209
+ date: query.date,
210
+ country: 'US',
211
+ };
212
+ const ranked = [];
213
+ const excluded = [];
214
+ for (const card of catalog.cards) {
215
+ if (!matchesFilters(card, options.filters))
216
+ continue;
217
+ const picks = bestPicksForPurchase(catalog, card, merchant, purchase, options);
218
+ const held = idealHolding(card, query.date, picks);
219
+ const wallet = walletOf(held, options);
220
+ const rec = recommend(catalog, wallet, purchase, merchant);
221
+ const top = rec.ranked[0];
222
+ if (!top) {
223
+ excluded.push(...rec.excluded);
224
+ continue;
225
+ }
226
+ const requirements = requirementsFor(catalog, card, held, wallet, merchant, purchase);
227
+ const gap = rotationGap(card, query.date);
228
+ const evidenceRule = top.matchedRule ?? 'base_rate';
229
+ const evidence = card.evidence.find((e) => e.rule === evidenceRule);
230
+ ranked.push({
231
+ ...top,
232
+ warnings: gap === null
233
+ ? top.warnings
234
+ : [
235
+ ...top.warnings,
236
+ `Its rotating categories for ${gap} aren't in earnrate's data yet, so it's ranked without them.`,
237
+ ],
238
+ issuer: card.issuer,
239
+ network: card.network,
240
+ annualFee: card.annual_fee ?? null,
241
+ firstYearFee: card.first_year_fee ?? null,
242
+ picks,
243
+ requirements,
244
+ conditions: conditionsText(catalog, requirements),
245
+ evidence: evidence ? { quote: evidence.quote, url: evidence.url } : null,
246
+ withMembership: membershipUpgrades(catalog, card, held, merchant, purchase, options, top.effectiveReturnPct),
247
+ });
248
+ }
249
+ ranked.sort(compareExplored);
250
+ return {
251
+ merchant,
252
+ ranked,
253
+ excluded,
254
+ assumptions: exploreAssumptions(catalog, merchant, ranked, options),
255
+ warnings: exploreWarnings(catalog, merchant),
256
+ };
257
+ }
258
+ function exploreAssumptions(catalog, merchant, ranked, options) {
259
+ const assumptions = [];
260
+ if (merchant.source === 'caller') {
261
+ assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
262
+ }
263
+ else if (merchant.source !== 'none') {
264
+ const code = merchant.likelyMcc === null ? '' : ` (MCC ${String(merchant.likelyMcc)})`;
265
+ assumptions.push(`Assumes ${merchant.resolvedName} codes as ${categoryPhrase(catalog, merchant.category)}${code}.`);
266
+ }
267
+ assumptions.push('Assumes each card is set up for this purchase: the right category pick, any activation done, and none of its cap used yet. A card you hold may differ.');
268
+ assumptions.push(options.memberships.length === 0
269
+ ? 'Assumes no memberships (like Amazon Prime). Rates that need one are listed separately.'
270
+ : `Assumes these memberships: ${options.memberships.join(', ')}.`);
271
+ const values = new Map();
272
+ for (const e of ranked.slice(0, 10)) {
273
+ if (e.currency !== 'cash')
274
+ values.set(e.currency, e.centsPerUnit);
275
+ }
276
+ if (values.size > 0) {
277
+ const list = [...values].map(([id, cents]) => `${currencyLabel(catalog, id)} at ${num(cents)}¢`);
278
+ assumptions.push(`Values points at: ${list.join(', ')}.`);
279
+ }
280
+ return assumptions;
281
+ }
282
+ function exploreWarnings(catalog, merchant) {
283
+ const warnings = [];
284
+ if (merchant.source === 'none') {
285
+ warnings.push(`Couldn't identify "${merchant.input}", so only base rates apply. Try a category instead.`);
286
+ }
287
+ if (merchant.ambiguity) {
288
+ const alternatives = merchant.ambiguity.mayCodeAs.map((c) => categoryPhrase(catalog, c));
289
+ warnings.push(`${merchant.resolvedName} may code as ${alternatives.join(' or ')} instead: ${merchant.ambiguity.note}`);
290
+ }
291
+ return warnings;
292
+ }
@@ -20,14 +20,14 @@ const PERIOD_WORDS = {
20
20
  cardmember_year: 'cardmember-year',
21
21
  };
22
22
  const displayName = (e) => e.held.checkout_label ?? e.card.name;
23
- const num = (n) => String(Number(n.toFixed(2)));
23
+ export const num = (n) => String(Number(n.toFixed(2)));
24
24
  // Whole dollars stay whole ($2,800); anything else shows cents ($0.40).
25
25
  const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
26
- const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
27
- function currencyName(catalog, id) {
26
+ export const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
27
+ export function currencyName(catalog, id) {
28
28
  return catalog.currencies.find((c) => c.id === id)?.name ?? id;
29
29
  }
30
- function currencyLabel(catalog, id) {
30
+ export function currencyLabel(catalog, id) {
31
31
  const name = currencyName(catalog, id);
32
32
  return /^[A-Z]{2,4}$/.test(id) ? `${name} (${id})` : name;
33
33
  }
@@ -39,13 +39,13 @@ function categoryName(catalog, id) {
39
39
  }
40
40
  // Mid-sentence form: "Supermarkets" becomes "supermarkets", but "TV and
41
41
  // internet service" and "EV charging" keep their acronyms.
42
- function categoryPhrase(catalog, id) {
42
+ export function categoryPhrase(catalog, id) {
43
43
  const name = categoryName(catalog, id);
44
44
  return /^[A-Z]{2}/.test(name)
45
45
  ? name
46
46
  : name.charAt(0).toLowerCase() + name.slice(1);
47
47
  }
48
- function earnText(catalog, rate, currency) {
48
+ export function earnText(catalog, rate, currency) {
49
49
  return currency === 'cash'
50
50
  ? `${num(rate)}% cash back`
51
51
  : `${num(rate)}x ${currencyName(catalog, currency)}`;
@@ -333,7 +333,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
333
333
  const gap = card ? rotationGap(card, purchase.date) : null;
334
334
  if (card && gap !== null) {
335
335
  const name = held.checkout_label ?? card.name;
336
- 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.`);
336
+ 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.`);
337
337
  }
338
338
  }
339
339
  const tie = tieNote(entries);
@@ -161,7 +161,7 @@ export function setupNotes(catalog, wallet, held, today) {
161
161
  if (gap !== null) {
162
162
  notes.push({
163
163
  kind: 'rotation',
164
- text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet.`,
164
+ text: `${name}'s rotating categories for ${gap} aren't in earnrate's data yet. An earnrate update adds them once the issuer announces them.`,
165
165
  });
166
166
  }
167
167
  return notes;
@@ -0,0 +1,309 @@
1
+ import { resolveMerchant } from "../merchants/resolve.js";
2
+ import { evaluateCard, purchaseContext } from "./evaluate.js";
3
+ import { categoryResolution, choicePeriod, idealHolding, matchesFilters, } from "./explore.js";
4
+ import { periodKey } from "./periods.js";
5
+ import { applyBooking, capBooking } from "./record.js";
6
+ import { rotationGap } from "./rotation.js";
7
+ import { cardValuation } from "./valuation.js";
8
+ const EPSILON = 1e-9;
9
+ const MONTHS = 12;
10
+ const SHORTLIST = 8; // best single cards considered for pairs
11
+ const PER_LINE = 2; // plus the best cards for each spending line
12
+ const round = (n) => Math.round(n * 100) / 100;
13
+ export function resolveSpendLines(catalog, lines) {
14
+ return lines
15
+ .filter((l) => l.monthly > 0)
16
+ .map((l) => {
17
+ const merchant = l.merchant !== null
18
+ ? resolveMerchant(catalog, l.merchant, null)
19
+ : categoryResolution(catalog, l.category ?? 'other', null);
20
+ return { label: merchant.resolvedName, monthly: l.monthly, merchant };
21
+ });
22
+ }
23
+ function monthDates(start) {
24
+ const year = Number(start.slice(0, 4));
25
+ const month = Number(start.slice(5, 7)) - 1;
26
+ return Array.from({ length: MONTHS }, (_, i) => {
27
+ const y = year + Math.floor((month + i) / 12);
28
+ const m = ((month + i) % 12) + 1;
29
+ return `${String(y)}-${String(m).padStart(2, '0')}-15`;
30
+ });
31
+ }
32
+ // Picks carry over from period to period, so a quarter's picks hold all year.
33
+ function withCurrentPicks(card, held, date) {
34
+ if (!held.chosen)
35
+ return held;
36
+ return {
37
+ ...held,
38
+ chosen: {
39
+ ...held.chosen,
40
+ period: periodKey(choicePeriod(card), date, null) ?? '',
41
+ },
42
+ };
43
+ }
44
+ function usableAt(card, merchant) {
45
+ if (card.only_at)
46
+ return (merchant.merchantId !== null && card.only_at.includes(merchant.merchantId));
47
+ return (card.network === 'store' ||
48
+ merchant.accepts === null ||
49
+ merchant.accepts.includes(card.network));
50
+ }
51
+ // Spends `lines` every month for a year. Each dollar goes to the card that
52
+ // earns the most on it right then, so spend moves to another card once a
53
+ // cap runs out.
54
+ export function simulateWallet(catalog, wallet, lines, start) {
55
+ const cards = wallet.cards.map((held) => {
56
+ const card = catalog.cards.find((c) => c.id === held.id);
57
+ if (!card)
58
+ throw new Error(`${held.id} isn't in the card catalog`);
59
+ return { card, cents: cardValuation(catalog, wallet, card).centsPerUnit };
60
+ });
61
+ let state = wallet;
62
+ const results = lines.map(() => ({
63
+ spend: 0,
64
+ earned: 0,
65
+ byCard: new Map(),
66
+ }));
67
+ for (const date of monthDates(start)) {
68
+ lines.forEach((line, i) => {
69
+ const result = results[i];
70
+ if (!result)
71
+ return;
72
+ let left = line.monthly;
73
+ result.spend += left;
74
+ for (let guard = 0; left > EPSILON && guard < 20; guard++) {
75
+ let pick = null;
76
+ for (const { card, cents } of cards) {
77
+ if (!usableAt(card, line.merchant))
78
+ continue;
79
+ const raw = state.cards.find((h) => h.id === card.id);
80
+ if (!raw)
81
+ continue;
82
+ const held = withCurrentPicks(card, raw, date);
83
+ const ctx = purchaseContext(catalog, state, line.merchant, line.merchant.category, { amount: null, date, country: 'US' });
84
+ const { best } = evaluateCard(card, held, ctx);
85
+ const perDollar = (best.rate * cents) / 100;
86
+ const room = best.cap && best.cap.remaining > 0 ? best.cap.remaining : Infinity;
87
+ if (!pick || perDollar > pick.perDollar + EPSILON)
88
+ pick = { card, perDollar, room };
89
+ }
90
+ if (!pick)
91
+ break;
92
+ const chosen = pick;
93
+ const amount = Math.min(left, chosen.room);
94
+ result.earned += amount * chosen.perDollar;
95
+ result.byCard.set(chosen.card.id, (result.byCard.get(chosen.card.id) ?? 0) + amount);
96
+ const booking = capBooking(catalog, {
97
+ ...state,
98
+ cards: state.cards.map((h) => h.id === chosen.card.id
99
+ ? withCurrentPicks(chosen.card, h, date)
100
+ : h),
101
+ }, {
102
+ cardId: chosen.card.id,
103
+ merchant: line.label,
104
+ amount,
105
+ date,
106
+ channel: line.merchant.channel,
107
+ }, line.merchant);
108
+ if (booking)
109
+ state = applyBooking(state, booking);
110
+ left -= amount;
111
+ }
112
+ });
113
+ }
114
+ const lineResults = lines.map((line, i) => {
115
+ const r = results[i] ?? {
116
+ spend: 0,
117
+ earned: 0,
118
+ byCard: new Map(),
119
+ };
120
+ return {
121
+ label: line.label,
122
+ spend: round(r.spend),
123
+ earned: round(r.earned),
124
+ returnPct: r.spend > 0 ? (r.earned / r.spend) * 100 : 0,
125
+ cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
126
+ };
127
+ });
128
+ const rewards = round(lineResults.reduce((sum, l) => sum + l.earned, 0));
129
+ const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
130
+ const notes = [];
131
+ const dates = monthDates(start);
132
+ for (const { card } of cards) {
133
+ const gaps = [
134
+ ...new Set(dates.map((d) => rotationGap(card, d)).filter((g) => g !== null)),
135
+ ];
136
+ if (gaps.length > 0)
137
+ 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.`);
138
+ if (card.annual_fee === undefined)
139
+ notes.push(`${card.name}'s annual fee isn't on file, so none is subtracted.`);
140
+ else if (card.first_year_fee !== undefined &&
141
+ card.first_year_fee !== card.annual_fee)
142
+ notes.push(`${card.name}'s fee is $${String(card.first_year_fee)} the first year; the yearly figure uses the $${String(card.annual_fee)} that follows.`);
143
+ }
144
+ return {
145
+ cards: wallet.cards.map((h) => ({
146
+ cardId: h.id,
147
+ picks: h.chosen?.picks ?? {},
148
+ })),
149
+ spend: round(lineResults.reduce((sum, l) => sum + l.spend, 0)),
150
+ rewards,
151
+ annualFees,
152
+ net: round(rewards - annualFees),
153
+ lines: lineResults,
154
+ notes,
155
+ };
156
+ }
157
+ function combinations(items, size) {
158
+ if (size === 0)
159
+ return [[]];
160
+ if (items.length < size)
161
+ return [items];
162
+ const [first, ...rest] = items;
163
+ if (first === undefined)
164
+ return [[]];
165
+ return [
166
+ ...combinations(rest, size - 1).map((c) => [first, ...c]),
167
+ ...combinations(rest, size),
168
+ ];
169
+ }
170
+ // Pick sets worth trying: only options that raise the card's rate on one of
171
+ // the lines, combined up to the number of picks the card allows.
172
+ function candidatePicks(catalog, card, lines, memberships, date) {
173
+ let sets = [{}];
174
+ for (const choice of card.choices) {
175
+ const useful = Object.keys(choice.options).filter((option) => {
176
+ const plain = idealHolding(card, date);
177
+ const picked = idealHolding(card, date, { [choice.id]: [option] });
178
+ return lines.some((line) => {
179
+ const ctx = purchaseContext(catalog, { valuations: {}, memberships, cards: [picked] }, line.merchant, line.merchant.category, { amount: null, date, country: 'US' });
180
+ return (evaluateCard(card, picked, ctx).best.rate >
181
+ evaluateCard(card, plain, ctx).best.rate + EPSILON);
182
+ });
183
+ });
184
+ if (useful.length === 0)
185
+ continue;
186
+ const combos = combinations(useful, Math.min(choice.pick, useful.length));
187
+ sets = sets.flatMap((s) => combos.map((c) => ({ ...s, [choice.id]: c })));
188
+ }
189
+ return sets;
190
+ }
191
+ function walletWith(base, card, picks, options) {
192
+ return {
193
+ valuations: options.valuations,
194
+ memberships: options.memberships,
195
+ cards: [...base, idealHolding(card, options.start, picks)],
196
+ };
197
+ }
198
+ // The best picks for `card` alongside the cards already in `base`.
199
+ function bestSimulation(catalog, base, card, lines, options) {
200
+ let best = null;
201
+ for (const picks of candidatePicks(catalog, card, lines, options.memberships, options.start)) {
202
+ const sim = simulateWallet(catalog, walletWith(base, card, picks, options), lines, options.start);
203
+ if (!best || sim.net > best.net + EPSILON)
204
+ best = sim;
205
+ }
206
+ if (!best)
207
+ throw new Error(`no simulation for ${card.id}`);
208
+ return best;
209
+ }
210
+ const byNet = (a, b) => b.net - a.net || a.annualFees - b.annualFees;
211
+ // Pairs that tie and share their first card differ only in an
212
+ // interchangeable second card (several flat 2% cards), so they fold into one.
213
+ function foldTies(pairs) {
214
+ const folded = [];
215
+ for (const pair of pairs) {
216
+ const [a, b] = pair.cards;
217
+ const twin = folded.find((f) => Math.abs(f.net - pair.net) < 0.005 &&
218
+ f.annualFees === pair.annualFees &&
219
+ f.cards.some((c) => c.cardId === a?.cardId || c.cardId === b?.cardId));
220
+ if (!twin) {
221
+ folded.push({ ...pair, alternatives: [] });
222
+ continue;
223
+ }
224
+ const shared = new Set(twin.cards.map((c) => c.cardId));
225
+ const other = [a, b].find((c) => c && !shared.has(c.cardId));
226
+ if (other)
227
+ twin.alternatives.push(other.cardId);
228
+ }
229
+ return folded;
230
+ }
231
+ export function simulateCatalog(catalog, lines, options) {
232
+ const eligible = catalog.cards.filter((c) => matchesFilters(c, options.filters));
233
+ const singles = eligible
234
+ .map((card) => bestSimulation(catalog, [], card, lines, options))
235
+ .sort(byNet);
236
+ const shortlist = new Set(singles.slice(0, SHORTLIST).map((s) => s.cards[0]?.cardId));
237
+ lines.forEach((line) => {
238
+ const best = [...singles].sort((a, b) => (b.lines.find((l) => l.label === line.label)?.earned ?? 0) -
239
+ (a.lines.find((l) => l.label === line.label)?.earned ?? 0));
240
+ for (const s of best.slice(0, PER_LINE))
241
+ shortlist.add(s.cards[0]?.cardId);
242
+ });
243
+ // A card whose points pool into a shortlisted card's is worth more beside it.
244
+ for (const card of eligible) {
245
+ if (card.pools_into?.when_wallet_has.some((id) => shortlist.has(id)))
246
+ shortlist.add(card.id);
247
+ }
248
+ const finalists = eligible.filter((c) => shortlist.has(c.id));
249
+ const singleFor = new Map(singles.map((s) => [s.cards[0]?.cardId, s]));
250
+ const pairs = [];
251
+ finalists.forEach((first, i) => {
252
+ const firstPicks = singleFor.get(first.id)?.cards[0]?.picks ?? {};
253
+ for (const second of finalists.slice(i + 1)) {
254
+ const base = [idealHolding(first, options.start, firstPicks)];
255
+ pairs.push(bestSimulation(catalog, base, second, lines, options));
256
+ }
257
+ });
258
+ pairs.sort(byNet);
259
+ return { singles, pairs: foldTies(pairs).slice(0, 10) };
260
+ }
261
+ // The person's own wallet, set up as it would run all year: their picks carry
262
+ // over, rotating categories get activated, caps start fresh.
263
+ function projectedWallet(wallet, catalog, start) {
264
+ return {
265
+ ...wallet,
266
+ cards: wallet.cards.map((held) => {
267
+ const card = catalog.cards.find((c) => c.id === held.id);
268
+ const ideal = card
269
+ ? idealHolding(card, start, held.chosen?.picks ?? {})
270
+ : held;
271
+ return {
272
+ ...ideal,
273
+ ...(held.checkout_label !== undefined
274
+ ? { checkout_label: held.checkout_label }
275
+ : {}),
276
+ ...(held.anniversary !== undefined
277
+ ? { anniversary: held.anniversary }
278
+ : {}),
279
+ };
280
+ }),
281
+ };
282
+ }
283
+ export function simulateAdditions(catalog, wallet, lines, options) {
284
+ const projected = projectedWallet(wallet, catalog, options.start);
285
+ const current = simulateWallet(catalog, projected, lines, options.start);
286
+ const held = new Set(wallet.cards.map((c) => c.id));
287
+ const additions = [];
288
+ for (const card of catalog.cards) {
289
+ if (held.has(card.id) || !matchesFilters(card, options.filters))
290
+ continue;
291
+ const simulation = bestSimulation(catalog, projected.cards, card, lines, {
292
+ ...options,
293
+ memberships: wallet.memberships,
294
+ valuations: wallet.valuations,
295
+ });
296
+ const extraRewards = round(simulation.rewards - current.rewards);
297
+ const annualFee = card.annual_fee ?? 0;
298
+ additions.push({
299
+ cardId: card.id,
300
+ picks: simulation.cards.at(-1)?.picks ?? {},
301
+ extraRewards,
302
+ annualFee,
303
+ gain: round(extraRewards - annualFee),
304
+ simulation,
305
+ });
306
+ }
307
+ additions.sort((a, b) => b.gain - a.gain || a.annualFee - b.annualFee);
308
+ return { current, additions };
309
+ }