earnrate 0.1.2 → 0.1.3

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,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
+ }
@@ -0,0 +1,179 @@
1
+ import { periodKey } from "./periods.js";
2
+ import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
3
+ const failed = (error) => ({ ok: false, error });
4
+ const list = (ids) => ids.join(', ');
5
+ export function findCatalogCard(catalog, id, today) {
6
+ const card = catalog.cards.find((c) => c.id === id);
7
+ if (card)
8
+ return card;
9
+ const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
10
+ return near.length > 0
11
+ ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
12
+ : `No card '${id}' in earnrate. Search the catalog to find its id.`;
13
+ }
14
+ export function isAnniversary(text) {
15
+ const m = /^(\d{2})-(\d{2})$/.exec(text);
16
+ if (!m)
17
+ return false;
18
+ const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
19
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
20
+ }
21
+ // The period key picks are filed under: the card's choice period, or for
22
+ // picks that carry over, the current year (it's never compared).
23
+ function pickPeriod(card, today) {
24
+ const periods = [
25
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
26
+ ];
27
+ if (periods.length > 1)
28
+ return null;
29
+ return periodKey(periods[0] ?? 'calendar_year', today, null);
30
+ }
31
+ export function cardFields(card, held, input, today) {
32
+ const problems = [];
33
+ const fields = {};
34
+ if (input.checkoutLabel !== undefined) {
35
+ if (input.checkoutLabel.trim() === '')
36
+ problems.push('The checkout label is empty.');
37
+ // earnrate never stores card numbers; a last four is fine.
38
+ if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
39
+ problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
40
+ fields.checkout_label = input.checkoutLabel.trim();
41
+ }
42
+ if (input.anniversary !== undefined) {
43
+ if (!isAnniversary(input.anniversary)) {
44
+ problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
45
+ }
46
+ fields.anniversary = input.anniversary;
47
+ }
48
+ if (input.activated !== undefined) {
49
+ const needing = card.rules
50
+ .filter((r) => r.requires?.activation)
51
+ .map((r) => r.id);
52
+ for (const id of input.activated) {
53
+ if (!needing.includes(id)) {
54
+ problems.push(needing.length > 0
55
+ ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
56
+ : `${card.id} has no rules that need activating.`);
57
+ }
58
+ }
59
+ fields.activated = [...new Set(input.activated)];
60
+ }
61
+ if (input.picks !== undefined) {
62
+ for (const [choiceId, picks] of Object.entries(input.picks)) {
63
+ const choice = card.choices.find((c) => c.id === choiceId);
64
+ if (!choice) {
65
+ problems.push(card.choices.length > 0
66
+ ? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
67
+ : `${card.id} has no categories to pick.`);
68
+ continue;
69
+ }
70
+ const options = Object.keys(choice.options);
71
+ for (const p of picks) {
72
+ if (!options.includes(p)) {
73
+ problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
74
+ }
75
+ }
76
+ if (picks.length === 0 || picks.length > choice.pick) {
77
+ problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
78
+ }
79
+ }
80
+ const period = pickPeriod(card, today);
81
+ if (period === null) {
82
+ problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
83
+ }
84
+ else {
85
+ const kept = held?.chosen?.period === period ? held.chosen.picks : {};
86
+ fields.chosen = {
87
+ period,
88
+ picks: { ...kept, ...input.picks },
89
+ };
90
+ }
91
+ }
92
+ return problems.length > 0 ? problems.join('\n') : fields;
93
+ }
94
+ export const newHolding = (id) => ({
95
+ id,
96
+ cap_usage: {},
97
+ activated: [],
98
+ });
99
+ export const withFields = (held, f) => ({
100
+ ...held,
101
+ ...(f.checkout_label === undefined
102
+ ? {}
103
+ : { checkout_label: f.checkout_label }),
104
+ ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
105
+ ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
106
+ ...(f.activated === undefined ? {} : { activated: f.activated }),
107
+ });
108
+ // `wallet` is null when there isn't one yet; adding a card starts it.
109
+ export function addToWallet(catalog, wallet, input, today) {
110
+ const card = findCatalogCard(catalog, input.id, today);
111
+ if (typeof card === 'string')
112
+ return failed(card);
113
+ if (wallet?.cards.some((c) => c.id === card.id)) {
114
+ return failed(`${card.id} is already in the wallet. Update it instead.`);
115
+ }
116
+ const fields = cardFields(card, null, input, today);
117
+ if (typeof fields === 'string')
118
+ return failed(fields);
119
+ const held = withFields(newHolding(card.id), fields);
120
+ const next = wallet
121
+ ? { ...wallet, cards: [...wallet.cards, held] }
122
+ : { valuations: {}, memberships: [], cards: [held] };
123
+ return {
124
+ ok: true,
125
+ wallet: next,
126
+ fields,
127
+ notes: setupNotes(catalog, next, held, today),
128
+ };
129
+ }
130
+ export function updateInWallet(catalog, wallet, input, today) {
131
+ const held = wallet.cards.find((c) => c.id === input.id);
132
+ const card = catalog.cards.find((c) => c.id === input.id);
133
+ if (!held || !card) {
134
+ return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
135
+ }
136
+ const fields = cardFields(card, held, input, today);
137
+ if (typeof fields === 'string')
138
+ return failed(fields);
139
+ if (Object.keys(fields).length === 0)
140
+ return failed('Nothing to change.');
141
+ const updated = withFields(held, fields);
142
+ const next = {
143
+ ...wallet,
144
+ cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
145
+ };
146
+ return {
147
+ ok: true,
148
+ wallet: next,
149
+ fields,
150
+ notes: setupNotes(catalog, next, updated, today),
151
+ };
152
+ }
153
+ export function removeFromWallet(wallet, id) {
154
+ if (!wallet.cards.some((c) => c.id === id)) {
155
+ return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
156
+ }
157
+ if (wallet.cards.length === 1) {
158
+ return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
159
+ }
160
+ return {
161
+ ok: true,
162
+ wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
163
+ fields: {},
164
+ notes: [],
165
+ };
166
+ }
167
+ export function withMemberships(catalog, wallet, memberships) {
168
+ const known = knownMemberships(catalog);
169
+ const unknown = memberships.filter((m) => !known.includes(m));
170
+ if (unknown.length > 0) {
171
+ return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
172
+ }
173
+ return {
174
+ ok: true,
175
+ wallet: { ...wallet, memberships: [...new Set(memberships)] },
176
+ fields: {},
177
+ notes: [],
178
+ };
179
+ }
@@ -218,3 +218,73 @@ export const WalletChangeSchema = z.object({
218
218
  text: z.string(),
219
219
  })),
220
220
  });
221
+ const ExcludedCardSchema = z.object({
222
+ cardId: z.string(),
223
+ cardName: z.string(),
224
+ checkoutLabel: z.string().nullable(),
225
+ network: Network,
226
+ reason: z.string(),
227
+ });
228
+ const Picks = z.record(z.string(), z.array(z.string()));
229
+ export const ExplorationSchema = z.object({
230
+ merchant: MerchantResolutionSchema,
231
+ ranked: z.array(RankedCardSchema.extend({
232
+ issuer: z.string(),
233
+ network: CardNetwork,
234
+ annualFee: z.number().nullable(),
235
+ firstYearFee: z.number().nullable(),
236
+ picks: Picks,
237
+ requirements: z.object({
238
+ pick: z
239
+ .object({
240
+ choice: z.string(),
241
+ options: z.array(z.string()),
242
+ period: z.string(),
243
+ })
244
+ .nullable(),
245
+ activation: z.string().nullable(),
246
+ membership: z.string().nullable(),
247
+ cap: z
248
+ .object({
249
+ amount: z.number(),
250
+ period: z.string(),
251
+ thenRate: z.number(),
252
+ currency: z.string(),
253
+ })
254
+ .nullable(),
255
+ }),
256
+ conditions: z.array(z.string()),
257
+ evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
258
+ withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
259
+ })),
260
+ excluded: z.array(ExcludedCardSchema),
261
+ assumptions: z.array(z.string()),
262
+ warnings: z.array(z.string()),
263
+ });
264
+ const SimulationSchema = z.object({
265
+ cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
266
+ spend: z.number(),
267
+ rewards: z.number(),
268
+ annualFees: z.number(),
269
+ net: z.number(),
270
+ lines: z.array(z.object({
271
+ label: z.string(),
272
+ spend: z.number(),
273
+ earned: z.number(),
274
+ returnPct: z.number(),
275
+ cards: z.array(z.string()),
276
+ })),
277
+ notes: z.array(z.string()),
278
+ });
279
+ export const SpendingSimulationSchema = z.object({
280
+ singles: z.array(SimulationSchema),
281
+ pairs: z.array(SimulationSchema.extend({ alternatives: z.array(z.string()) })),
282
+ current: SimulationSchema.nullable(),
283
+ additions: z.array(z.object({
284
+ cardId: z.string(),
285
+ picks: Picks,
286
+ extraRewards: z.number(),
287
+ annualFee: z.number(),
288
+ gain: z.number(),
289
+ })),
290
+ });