earnrate 0.2.8 → 0.2.9
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 +31 -5
- package/data/CHANGELOG.md +133 -0
- package/data/VERSION +1 -1
- package/data/cards/amex-blue-business-plus.yaml +4 -0
- package/data/cards/amex-blue-cash-everyday.yaml +4 -0
- package/data/cards/amex-blue-cash-preferred.yaml +36 -3
- package/data/cards/amex-gold.yaml +4 -0
- package/data/cards/amex-platinum.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-business.yaml +4 -0
- package/data/cards/bank-of-america-atmos-rewards-summit.yaml +24 -1
- package/data/cards/bank-of-america-customized-cash-rewards.yaml +4 -0
- package/data/cards/bilt-obsidian.yaml +4 -0
- package/data/cards/capital-one-kohls-card.yaml +2 -0
- package/data/cards/capital-one-kohls-rewards-visa.yaml +2 -0
- package/data/cards/capital-one-savor.yaml +4 -0
- package/data/cards/capital-one-venture-x.yaml +4 -0
- package/data/cards/capital-one-venture.yaml +4 -0
- package/data/cards/chase-aeroplan.yaml +5 -0
- package/data/cards/chase-freedom-flex.yaml +4 -0
- package/data/cards/chase-freedom-unlimited.yaml +4 -0
- package/data/cards/chase-ihg-one-rewards-business.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards-premier-select.yaml +37 -2
- package/data/cards/chase-ihg-one-rewards-premier.yaml +29 -1
- package/data/cards/chase-ihg-one-rewards.yaml +57 -7
- package/data/cards/chase-ink-business-cash.yaml +4 -0
- package/data/cards/chase-ink-business-preferred.yaml +4 -0
- package/data/cards/chase-prime-visa.yaml +4 -0
- package/data/cards/chase-sapphire-preferred.yaml +4 -0
- package/data/cards/chase-sapphire-reserve.yaml +4 -0
- package/data/cards/chase-southwest-performance-business.yaml +4 -0
- package/data/cards/chase-southwest-plus.yaml +4 -0
- package/data/cards/chase-southwest-premier-business.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-business.yaml +5 -0
- package/data/cards/chase-united-club-business.yaml +5 -0
- package/data/cards/chase-united-club.yaml +5 -0
- package/data/cards/chase-united-explorer.yaml +5 -0
- package/data/cards/chase-united-gateway.yaml +5 -0
- package/data/cards/chase-united-quest.yaml +29 -1
- package/data/cards/citi-aadvantage-business.yaml +4 -0
- package/data/cards/citi-aadvantage-executive.yaml +4 -0
- package/data/cards/citi-aadvantage-globe.yaml +4 -0
- package/data/cards/citi-aadvantage-mileup.yaml +4 -0
- package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa-business.yaml +4 -0
- package/data/cards/citi-costco-anywhere-visa.yaml +4 -0
- package/data/cards/citi-custom-cash.yaml +2 -0
- package/data/cards/citi-double-cash.yaml +4 -0
- package/data/cards/citi-my-best-buy-visa.yaml +4 -0
- package/data/cards/citi-strata-elite.yaml +4 -0
- package/data/cards/citi-strata-premier.yaml +4 -0
- package/data/cards/sofi-unlimited-2.yaml +4 -0
- package/data/cards/td-bank-nordstrom-credit-card.yaml +2 -0
- package/data/cards/td-bank-nordstrom-visa.yaml +2 -0
- package/data/cards/td-bank-target-circle-card.yaml +2 -0
- package/data/cards/usbank-cash-plus.yaml +5 -0
- package/data/cards/wells-fargo-active-cash.yaml +4 -0
- package/data/cards/wells-fargo-autograph.yaml +4 -0
- package/data/merchants/overrides.yaml +284 -0
- package/data/merchants/seed.yaml +1055 -2
- package/dist/cli/args.js +3 -6
- package/dist/cli/explore.js +10 -0
- package/dist/cli/import.js +18 -4
- package/dist/cli/run.js +3 -1
- package/dist/cli/wallet.js +5 -1
- package/dist/data/file-store.js +2 -2
- package/dist/data/integrity.js +17 -0
- package/dist/data/store.js +1 -1
- package/dist/data/wallet-edit.js +32 -18
- package/dist/engine/cheat-sheet.js +46 -2
- package/dist/engine/checklist.js +19 -8
- package/dist/engine/evaluate.js +2 -0
- package/dist/engine/explore.js +1 -1
- package/dist/engine/milestones.js +83 -0
- package/dist/engine/periods.js +29 -12
- package/dist/engine/recommend.js +39 -9
- package/dist/engine/record.js +39 -1
- package/dist/engine/rule-text.js +5 -1
- package/dist/engine/setup.js +13 -2
- package/dist/engine/simulate.js +25 -3
- package/dist/engine/text.js +11 -0
- package/dist/engine/wallet-change.js +67 -0
- package/dist/engine/wallet-summary.js +17 -5
- package/dist/format/cards.js +3 -1
- package/dist/format/wallet.js +18 -4
- package/dist/import/analyze.js +42 -2
- package/dist/mcp/inputs.js +10 -0
- package/dist/mcp/schemas.js +47 -2
- package/dist/mcp/tools/account.js +1 -1
- package/dist/mcp/tools/answers.js +8 -4
- package/dist/mcp/tools/wallet.js +18 -10
- package/dist/schema/card.js +22 -1
- package/dist/schema/common.js +4 -0
- package/dist/schema/wallet.js +2 -0
- package/package.json +5 -3
package/dist/engine/recommend.js
CHANGED
|
@@ -2,13 +2,19 @@ import { findMerchant, resolveMerchant, resolveMerchantById, } from "../merchant
|
|
|
2
2
|
import { bonusStatus } from "./bonus.js";
|
|
3
3
|
import { evaluateCard, purchaseContext, usableAt, } from "./evaluate.js";
|
|
4
4
|
import { membershipName } from "./memberships.js";
|
|
5
|
+
import { milestonePoints, milestonesReached, reachedText, } from "./milestones.js";
|
|
5
6
|
import { daysBetween } from "./periods.js";
|
|
6
7
|
import { ruleWhat } from "./rule-text.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
9
|
import { EPSILON, isClose } from "./compare.js";
|
|
9
|
-
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
10
|
+
import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, earnText, listed, money, NETWORK_NAMES, num, pct, pointValuesText, roundCents, shortDate, wholeDollars, } from "./text.js";
|
|
10
11
|
import { cardValuation } from "./valuation.js";
|
|
11
12
|
export const STALE_AFTER_DAYS = 180;
|
|
13
|
+
// The engine only tells the U.S. from everywhere else.
|
|
14
|
+
export const isForeign = (purchase) => purchase.country !== 'US';
|
|
15
|
+
// Any purchase outside the U.S., when no country is named: ZZ is a code ISO
|
|
16
|
+
// leaves unassigned, and nothing prints it.
|
|
17
|
+
export const ABROAD = 'ZZ';
|
|
12
18
|
// The warning when no card works; the CLI shows it as the answer instead.
|
|
13
19
|
export const noCardText = (merchant) => `None of your cards work at ${merchant}.`;
|
|
14
20
|
const NEAR_CAP_SHARE = 0.2;
|
|
@@ -66,14 +72,22 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
|
|
|
66
72
|
}
|
|
67
73
|
const valuation = cardValuation(catalog, wallet, card);
|
|
68
74
|
const evaluation = evaluateCard(card, held, ctx);
|
|
69
|
-
const
|
|
75
|
+
const milestones = milestonesReached(card, held, purchase.date, purchase.amount);
|
|
76
|
+
const bonusPoints = milestonePoints(milestones);
|
|
77
|
+
const perDollar = evaluation.best.rate +
|
|
78
|
+
(bonusPoints > 0 && purchase.amount ? bonusPoints / purchase.amount : 0);
|
|
79
|
+
// An unknown fee isn't guessed: it's warned about instead.
|
|
80
|
+
const fee = isForeign(purchase) ? (card.foreign_transaction_fee ?? 0) : 0;
|
|
70
81
|
entries.push({
|
|
71
82
|
card,
|
|
72
83
|
held,
|
|
73
84
|
valuation,
|
|
74
85
|
evaluation,
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
milestones,
|
|
87
|
+
perDollar,
|
|
88
|
+
fee,
|
|
89
|
+
effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
|
|
90
|
+
upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
|
|
77
91
|
});
|
|
78
92
|
}
|
|
79
93
|
entries.sort(compareEntries);
|
|
@@ -96,6 +110,8 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
96
110
|
const over = amount - cap.remaining;
|
|
97
111
|
lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
|
|
98
112
|
}
|
|
113
|
+
for (const hit of entry.milestones)
|
|
114
|
+
lines.push(reachedText(card, hit));
|
|
99
115
|
if (valuation.pooledFrom !== null) {
|
|
100
116
|
const partners = card.pools_into?.when_wallet_has ?? [];
|
|
101
117
|
const heldPartners = wallet.cards
|
|
@@ -106,7 +122,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
|
|
|
106
122
|
lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
|
|
107
123
|
}
|
|
108
124
|
if (currency !== 'cash') {
|
|
109
|
-
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.
|
|
125
|
+
lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.perDollar * valuation.centsPerUnit)} back.`);
|
|
110
126
|
}
|
|
111
127
|
return lines;
|
|
112
128
|
}
|
|
@@ -114,6 +130,10 @@ function warningsFor(catalog, entry, purchase) {
|
|
|
114
130
|
const { card, valuation, evaluation } = entry;
|
|
115
131
|
const { best, nominal } = evaluation;
|
|
116
132
|
const warnings = [];
|
|
133
|
+
if (entry.fee > 0)
|
|
134
|
+
warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
|
|
135
|
+
if (isForeign(purchase) && card.foreign_transaction_fee === null)
|
|
136
|
+
warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
|
|
117
137
|
const capLabel = (e) => e.cap
|
|
118
138
|
? `${money(e.cap.cap.amount)} ${CAP_ADJECTIVES[e.cap.cap.period]} cap`
|
|
119
139
|
: '';
|
|
@@ -165,6 +185,7 @@ function toRanked(catalog, wallet, entry, purchase) {
|
|
|
165
185
|
currency: valuation.currency,
|
|
166
186
|
centsPerUnit: valuation.centsPerUnit,
|
|
167
187
|
effectiveReturnPct: entry.effectiveReturnPct,
|
|
188
|
+
foreignFee: entry.fee > 0 ? entry.fee : null,
|
|
168
189
|
earned: purchase.amount === null
|
|
169
190
|
? null
|
|
170
191
|
: Math.round(purchase.amount * entry.effectiveReturnPct) / 100,
|
|
@@ -208,16 +229,17 @@ function sensitivity(catalog, entries) {
|
|
|
208
229
|
const o = other.valuation;
|
|
209
230
|
if (w.currency === o.currency)
|
|
210
231
|
continue;
|
|
211
|
-
const wRate = winner.
|
|
212
|
-
const oRate = other.
|
|
232
|
+
const wRate = winner.perDollar;
|
|
233
|
+
const oRate = other.perDollar;
|
|
234
|
+
// Net return is rate × value − fee, so each flip point adds the fee back.
|
|
213
235
|
if (w.range && wRate > 0) {
|
|
214
|
-
const threshold = other.effectiveReturnPct / wRate;
|
|
236
|
+
const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
|
|
215
237
|
if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
|
|
216
238
|
consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)} points are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
|
|
217
239
|
}
|
|
218
240
|
}
|
|
219
241
|
if (o.range && oRate > 0) {
|
|
220
|
-
const threshold = winner.effectiveReturnPct / oRate;
|
|
242
|
+
const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
|
|
221
243
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
222
244
|
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
|
|
223
245
|
}
|
|
@@ -251,6 +273,11 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
|
|
|
251
273
|
const values = pointValuesText(catalog, entries.map((e) => e.valuation));
|
|
252
274
|
if (values !== null)
|
|
253
275
|
assumptions.push(values);
|
|
276
|
+
if (isForeign(purchase))
|
|
277
|
+
assumptions.push('Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.');
|
|
278
|
+
for (const e of entries)
|
|
279
|
+
for (const hit of e.milestones)
|
|
280
|
+
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.`);
|
|
254
281
|
return assumptions;
|
|
255
282
|
}
|
|
256
283
|
export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(catalog, purchase.merchant, purchase.channel)) {
|
|
@@ -259,6 +286,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
259
286
|
const winner = entries[0];
|
|
260
287
|
const warnings = [];
|
|
261
288
|
const notes = [];
|
|
289
|
+
const charging = entries.filter((e) => e.fee > 0);
|
|
290
|
+
if (charging.length > 0 && entries.length > 1)
|
|
291
|
+
notes.push(`${listed(charging.map((e) => `${displayName(e)} (${pct(e.fee)})`))} ${charging.length === 1 ? 'charges a foreign transaction fee' : 'charge foreign transaction fees'} here, already subtracted in the ranking.`);
|
|
262
292
|
if (merchant.source === 'none') {
|
|
263
293
|
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.`);
|
|
264
294
|
}
|
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 { milestoneProgress } from "./milestones.js";
|
|
4
5
|
import { roundCents } from "./text.js";
|
|
5
6
|
// Books the spend against the cap of the highest-rate rule that matches, even
|
|
6
7
|
// when that cap is already used up, so usage stays accurate across a reset.
|
|
@@ -75,11 +76,48 @@ export function applyBonusBooking(wallet, b) {
|
|
|
75
76
|
: c),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
79
|
+
// Every purchase on a card counts toward each of its spend milestones. None
|
|
80
|
+
// for a cardmember-year milestone on a card with no anniversary.
|
|
81
|
+
export function milestoneBookings(catalog, wallet, purchase) {
|
|
82
|
+
const held = wallet.cards.find((c) => c.id === purchase.cardId);
|
|
83
|
+
const card = catalog.cards.find((c) => c.id === purchase.cardId);
|
|
84
|
+
if (!held || !card)
|
|
85
|
+
return [];
|
|
86
|
+
return milestoneProgress(card, held, purchase.date).flatMap((p) => p.period === null
|
|
87
|
+
? []
|
|
88
|
+
: [
|
|
89
|
+
{
|
|
90
|
+
cardId: card.id,
|
|
91
|
+
milestoneId: p.milestone.id,
|
|
92
|
+
period: p.period,
|
|
93
|
+
spend: p.milestone.spend,
|
|
94
|
+
before: p.spent,
|
|
95
|
+
after: roundCents(Math.max(0, p.spent + purchase.amount)),
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
export function applyMilestoneBooking(wallet, b) {
|
|
100
|
+
return {
|
|
101
|
+
...wallet,
|
|
102
|
+
cards: wallet.cards.map((c) => c.id === b.cardId
|
|
103
|
+
? {
|
|
104
|
+
...c,
|
|
105
|
+
milestone_usage: {
|
|
106
|
+
...c.milestone_usage,
|
|
107
|
+
[b.milestoneId]: { period: b.period, spent: b.after },
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
: c),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
78
113
|
export function bookPurchase(catalog, wallet, purchase, merchant) {
|
|
79
114
|
const cap = capBooking(catalog, wallet, purchase, merchant);
|
|
80
115
|
const bonus = bonusBooking(catalog, wallet, purchase);
|
|
116
|
+
const milestones = milestoneBookings(catalog, wallet, purchase);
|
|
81
117
|
let next = cap ? applyBooking(wallet, cap) : wallet;
|
|
82
118
|
if (bonus)
|
|
83
119
|
next = applyBonusBooking(next, bonus);
|
|
84
|
-
|
|
120
|
+
for (const m of milestones)
|
|
121
|
+
next = applyMilestoneBooking(next, m);
|
|
122
|
+
return { wallet: next, cap, bonus, milestones };
|
|
85
123
|
}
|
package/dist/engine/rule-text.js
CHANGED
|
@@ -26,7 +26,11 @@ export function optionName(catalog, card, choiceId, option) {
|
|
|
26
26
|
// What a rule covers, as a list of phrases.
|
|
27
27
|
export function ruleParts(catalog, rule) {
|
|
28
28
|
if (rule.match.any)
|
|
29
|
-
return [
|
|
29
|
+
return [
|
|
30
|
+
rule.geography === 'foreign_only'
|
|
31
|
+
? 'purchases outside the U.S.'
|
|
32
|
+
: 'everything else',
|
|
33
|
+
];
|
|
30
34
|
if (rule.match.choice !== undefined)
|
|
31
35
|
return [`your ${choiceName(rule.match.choice)} picks`];
|
|
32
36
|
const merchants = rule.match.merchants ?? [];
|
package/dist/engine/setup.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isActivated, pickedCategories } from "./evaluate.js";
|
|
2
2
|
import { membership, membershipName, sameGroup } from "./memberships.js";
|
|
3
|
+
import { milestoneWhat } from "./milestones.js";
|
|
3
4
|
import { daysBetween } from "./periods.js";
|
|
4
5
|
import { rotationGap } from "./rotation.js";
|
|
5
6
|
import { idList, listed } from "./text.js";
|
|
@@ -39,7 +40,8 @@ function matcher(catalog, card) {
|
|
|
39
40
|
}
|
|
40
41
|
export function needsAnniversary(card) {
|
|
41
42
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
42
|
-
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year')
|
|
43
|
+
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year') ||
|
|
44
|
+
card.milestones.some((m) => m.period === 'cardmember_year'));
|
|
43
45
|
}
|
|
44
46
|
export function activationRules(card, today) {
|
|
45
47
|
return card.rules
|
|
@@ -74,6 +76,7 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
74
76
|
network: card.network,
|
|
75
77
|
currency: card.currency,
|
|
76
78
|
annualFee: card.annual_fee ?? null,
|
|
79
|
+
foreignTransactionFee: card.foreign_transaction_fee,
|
|
77
80
|
business: card.business,
|
|
78
81
|
onlyAt: card.only_at ?? null,
|
|
79
82
|
held: wallet?.cards.some((c) => c.id === card.id) ?? false,
|
|
@@ -94,6 +97,14 @@ export function cardInfo(catalog, card, wallet, today) {
|
|
|
94
97
|
},
|
|
95
98
|
activation: activationRules(card, today),
|
|
96
99
|
needsAnniversary: needsAnniversary(card),
|
|
100
|
+
milestones: card.milestones.map((m) => ({
|
|
101
|
+
id: m.id,
|
|
102
|
+
spend: m.spend,
|
|
103
|
+
period: m.period,
|
|
104
|
+
points: m.points ?? null,
|
|
105
|
+
rewards: m.rewards ?? [],
|
|
106
|
+
text: milestoneWhat(m).replace(/^the /, ''),
|
|
107
|
+
})),
|
|
97
108
|
};
|
|
98
109
|
}
|
|
99
110
|
// Every word of the query has to match a word of the card's id, name or
|
|
@@ -162,7 +173,7 @@ export function setupNotes(catalog, wallet, held, today) {
|
|
|
162
173
|
if (needsAnniversary(card) && held.anniversary === undefined) {
|
|
163
174
|
notes.push({
|
|
164
175
|
kind: 'anniversary',
|
|
165
|
-
text: `${name} has a cap that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
|
|
176
|
+
text: `${name} has a ${card.milestones.some((m) => m.period === 'cardmember_year') ? 'spend bonus' : 'cap'} that resets on the card's anniversary. Add the anniversary (MM-DD) so it can be tracked.`,
|
|
166
177
|
});
|
|
167
178
|
}
|
|
168
179
|
const gap = rotationGap(card, today);
|
package/dist/engine/simulate.js
CHANGED
|
@@ -4,8 +4,9 @@ import { idealHolding, matchesFilters, } from "./explore.js";
|
|
|
4
4
|
import { membershipCosts, membershipNote, paidMemberships, } from "./memberships.js";
|
|
5
5
|
import { choicePeriod, periodKey } from "./periods.js";
|
|
6
6
|
import { applyBooking, capBooking } from "./record.js";
|
|
7
|
+
import { milestoneWhat } from "./milestones.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
|
-
import { money, roundCents } from "./text.js";
|
|
9
|
+
import { money, roundCents, wholeDollars } from "./text.js";
|
|
9
10
|
import { cardValuation } from "./valuation.js";
|
|
10
11
|
import { EPSILON, HALF_CENT } from "./compare.js";
|
|
11
12
|
const MONTHS = 12;
|
|
@@ -122,7 +123,27 @@ held = []) {
|
|
|
122
123
|
cards: [...r.byCard].sort((a, b) => b[1] - a[1]).map(([id]) => id),
|
|
123
124
|
};
|
|
124
125
|
});
|
|
125
|
-
|
|
126
|
+
// A year of these amounts is taken as every year's: a calendar-year or
|
|
127
|
+
// cardmember-year total is the same twelve months' spending.
|
|
128
|
+
const milestones = [];
|
|
129
|
+
const milestoneNotes = [];
|
|
130
|
+
for (const { card, cents } of cards) {
|
|
131
|
+
const spent = results.reduce((sum, r) => sum + (r.byCard.get(card.id) ?? 0), 0);
|
|
132
|
+
for (const m of card.milestones) {
|
|
133
|
+
if (spent + EPSILON < m.spend)
|
|
134
|
+
continue;
|
|
135
|
+
const value = roundCents(((m.points ?? 0) * cents) / 100);
|
|
136
|
+
milestones.push({
|
|
137
|
+
cardId: card.id,
|
|
138
|
+
milestoneId: m.id,
|
|
139
|
+
points: m.points ?? null,
|
|
140
|
+
value,
|
|
141
|
+
});
|
|
142
|
+
milestoneNotes.push(`${card.name}: ${wholeDollars(spent)} a year on it reaches ${milestoneWhat(m)}${m.points === undefined ? '' : ` (about ${money(value)}, counted in its rewards)`}, assuming the same spending each year.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const rewards = roundCents(lineResults.reduce((sum, l) => sum + l.earned, 0) +
|
|
146
|
+
milestones.reduce((sum, m) => sum + m.value, 0));
|
|
126
147
|
const annualFees = cards.reduce((sum, c) => sum + (c.card.annual_fee ?? 0), 0);
|
|
127
148
|
const paid = paidMemberships(catalog, {
|
|
128
149
|
memberships: wallet.memberships,
|
|
@@ -131,7 +152,7 @@ held = []) {
|
|
|
131
152
|
});
|
|
132
153
|
const memberships = membershipCosts(catalog, cards.map((c) => c.card), paid);
|
|
133
154
|
const membershipFees = memberships.reduce((sum, m) => sum + m.annualFee, 0);
|
|
134
|
-
const notes = [];
|
|
155
|
+
const notes = [...milestoneNotes];
|
|
135
156
|
for (const m of memberships)
|
|
136
157
|
notes.push(membershipNote(catalog, m));
|
|
137
158
|
const dates = monthDates(start);
|
|
@@ -160,6 +181,7 @@ held = []) {
|
|
|
160
181
|
membershipFees,
|
|
161
182
|
net: roundCents(rewards - annualFees - membershipFees),
|
|
162
183
|
lines: lineResults,
|
|
184
|
+
milestones,
|
|
163
185
|
notes,
|
|
164
186
|
};
|
|
165
187
|
}
|
package/dist/engine/text.js
CHANGED
|
@@ -32,6 +32,12 @@ export const wholeDollars = (n) => money(Math.round(n));
|
|
|
32
32
|
// Whole dollars stay whole ($2,800); anything else shows cents ($0.40). A
|
|
33
33
|
// loss reads −$618.
|
|
34
34
|
export const money = (n) => `${n < 0 ? '−' : ''}$${Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 })}`;
|
|
35
|
+
// "No foreign transaction fee", "3% foreign transaction fee"
|
|
36
|
+
export const foreignFeeText = (fee) => fee === null
|
|
37
|
+
? 'Foreign transaction fee not on file'
|
|
38
|
+
: fee === 0
|
|
39
|
+
? 'No foreign transaction fee'
|
|
40
|
+
: `${pct(fee)} foreign transaction fee`;
|
|
35
41
|
export function feeText(fees) {
|
|
36
42
|
const { annualFee, firstYearFee } = fees;
|
|
37
43
|
if (annualFee === null)
|
|
@@ -122,6 +128,11 @@ export const CAP_ADJECTIVES = {
|
|
|
122
128
|
calendar_year: 'calendar-year',
|
|
123
129
|
cardmember_year: 'cardmember-year',
|
|
124
130
|
};
|
|
131
|
+
// "the $15,000 calendar-year spend", for a spend milestone.
|
|
132
|
+
export const MILESTONE_PERIODS = {
|
|
133
|
+
calendar_year: 'calendar-year',
|
|
134
|
+
cardmember_year: 'cardmember-year',
|
|
135
|
+
};
|
|
125
136
|
export const PICK_PERIODS = {
|
|
126
137
|
month: 'each month',
|
|
127
138
|
quarter: 'each quarter',
|
|
@@ -116,6 +116,28 @@ export function cardFields(card, held, input, today) {
|
|
|
116
116
|
}
|
|
117
117
|
fields.cap_usage = usage;
|
|
118
118
|
}
|
|
119
|
+
if (input.milestoneSpent !== undefined) {
|
|
120
|
+
const usage = { ...held?.milestone_usage };
|
|
121
|
+
const anniversary = fields.anniversary ?? held?.anniversary ?? null;
|
|
122
|
+
for (const [id, spent] of Object.entries(input.milestoneSpent)) {
|
|
123
|
+
const milestone = card.milestones.find((m) => m.id === id);
|
|
124
|
+
if (!milestone) {
|
|
125
|
+
problems.push(card.milestones.length > 0
|
|
126
|
+
? `${card.id} has no spend milestone '${id}'. Its milestones are: ${idList(card.milestones.map((m) => m.id))}.`
|
|
127
|
+
: `${card.id} has no spend milestones.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!(spent >= 0))
|
|
131
|
+
problems.push('Spending toward a milestone must be $0 or more.');
|
|
132
|
+
const period = periodKey(milestone.period, today, anniversary);
|
|
133
|
+
if (period === null) {
|
|
134
|
+
problems.push(`The '${id}' milestone resets on the card's anniversary; give the anniversary (MM-DD) too.`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
usage[id] = { period, spent };
|
|
138
|
+
}
|
|
139
|
+
fields.milestone_usage = usage;
|
|
140
|
+
}
|
|
119
141
|
if (input.bonus === null)
|
|
120
142
|
fields.bonus = null;
|
|
121
143
|
else if (input.bonus !== undefined) {
|
|
@@ -173,6 +195,9 @@ const withFields = ({ bonus, checkout_label, ...held }, f) => ({
|
|
|
173
195
|
...(f.chosen === undefined ? {} : { chosen: f.chosen }),
|
|
174
196
|
...(f.activated === undefined ? {} : { activated: f.activated }),
|
|
175
197
|
...(f.cap_usage === undefined ? {} : { cap_usage: f.cap_usage }),
|
|
198
|
+
...(f.milestone_usage === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { milestone_usage: f.milestone_usage }),
|
|
176
201
|
...(f.bonus ? { bonus: f.bonus } : {}),
|
|
177
202
|
});
|
|
178
203
|
// `wallet` is null when there isn't one yet; adding a card starts it.
|
|
@@ -266,3 +291,45 @@ export function withValuations(catalog, wallet, changes) {
|
|
|
266
291
|
}
|
|
267
292
|
// Higher than any program's points are worth, even transferred.
|
|
268
293
|
const MAX_CENTS = 5;
|
|
294
|
+
// Point values keyed the way people say them ("ur", "Ultimate Rewards",
|
|
295
|
+
// "Chase points"), turned into currency ids. A key that could mean more
|
|
296
|
+
// than one currency is refused rather than guessed.
|
|
297
|
+
export function currencyChanges(catalog, changes) {
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [key, cents] of Object.entries(changes)) {
|
|
300
|
+
const matches = currenciesFor(catalog, key);
|
|
301
|
+
const only = matches[0];
|
|
302
|
+
if (matches.length > 1)
|
|
303
|
+
return failed(`'${key}' could be ${matches.map((c) => `${c.id} (${c.name})`).join(' or ')}; give the currency id.`);
|
|
304
|
+
if (only === undefined)
|
|
305
|
+
return failed(`Unknown currency '${key}'. Currencies: ${catalog.currencies.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
|
|
306
|
+
if (only.id in out)
|
|
307
|
+
return failed(`More than one value was given for ${only.name}.`);
|
|
308
|
+
out[only.id] = cents;
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, changes: out };
|
|
311
|
+
}
|
|
312
|
+
const IGNORED_WORDS = new Set(['points', 'point', 'pts', 'miles', 'mile']);
|
|
313
|
+
const words = (text) => text
|
|
314
|
+
.toLowerCase()
|
|
315
|
+
.split(/[^a-z0-9]+/)
|
|
316
|
+
.filter((w) => w !== '' && !IGNORED_WORDS.has(w));
|
|
317
|
+
const sameWords = (a, b) => a.length === b.length && a.every((w, i) => w === b[i]);
|
|
318
|
+
function currenciesFor(catalog, key) {
|
|
319
|
+
const exact = catalog.currencies.find((c) => c.id === key);
|
|
320
|
+
if (exact)
|
|
321
|
+
return [exact];
|
|
322
|
+
const k = words(key);
|
|
323
|
+
if (k.length === 0)
|
|
324
|
+
return [];
|
|
325
|
+
const byId = catalog.currencies.filter((c) => sameWords(words(c.id), k));
|
|
326
|
+
if (byId.length > 0)
|
|
327
|
+
return byId;
|
|
328
|
+
const byName = catalog.currencies.filter((c) => sameWords(words(c.name), k));
|
|
329
|
+
if (byName.length > 0)
|
|
330
|
+
return byName;
|
|
331
|
+
return catalog.currencies.filter((c) => {
|
|
332
|
+
const name = words(c.name);
|
|
333
|
+
return name.some((_, i) => sameWords(name.slice(i, i + k.length), k));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
2
|
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
3
|
import { membershipName } from "./memberships.js";
|
|
4
|
+
import { milestoneSummaries } from "./milestones.js";
|
|
4
5
|
import { optionName, ruleParts, ruleWhat } from "./rule-text.js";
|
|
5
|
-
import { daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
6
|
+
import { choicePeriod, daysBetween, inEffect, periodEnd } from "./periods.js";
|
|
6
7
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
7
8
|
import { rotationGap } from "./rotation.js";
|
|
8
9
|
import { activationRules } from "./setup.js";
|
|
@@ -77,7 +78,9 @@ function headlineRates(catalog, wallet, card, held, currency, today) {
|
|
|
77
78
|
const after = `${limit}${activate}`;
|
|
78
79
|
return `${head} ${listed(whats)}${after === '' ? '' : ` (${after.replace(/^, /, '')})`}`;
|
|
79
80
|
}),
|
|
80
|
-
...(shown.some((r) => r.match.any &&
|
|
81
|
+
...(shown.some((r) => r.match.any &&
|
|
82
|
+
r.geography !== 'foreign_only' &&
|
|
83
|
+
r.rate > card.base_rate)
|
|
81
84
|
? []
|
|
82
85
|
: [`${earnText(catalog, card.base_rate, currency)} on everything else`]),
|
|
83
86
|
];
|
|
@@ -114,6 +117,13 @@ function toActivate(catalog, card, held, today) {
|
|
|
114
117
|
return `${a.what}${when === '' ? '' : ` (${when})`}`;
|
|
115
118
|
});
|
|
116
119
|
}
|
|
120
|
+
// Picks that carry over have no end date.
|
|
121
|
+
function picksUntil(card, key) {
|
|
122
|
+
if (card.choices.every((c) => c.period === 'until_changed'))
|
|
123
|
+
return null;
|
|
124
|
+
const period = choicePeriod(card);
|
|
125
|
+
return period === null ? null : periodEnd(period, key, null);
|
|
126
|
+
}
|
|
117
127
|
function capSummaries(catalog, card, held, today) {
|
|
118
128
|
const caps = new Map();
|
|
119
129
|
for (const shared of card.caps)
|
|
@@ -143,6 +153,9 @@ function capSummaries(catalog, card, held, today) {
|
|
|
143
153
|
amount: cap.amount,
|
|
144
154
|
period: cap.period,
|
|
145
155
|
currentPeriod,
|
|
156
|
+
until: currentPeriod === null
|
|
157
|
+
? null
|
|
158
|
+
: periodEnd(cap.period, currentPeriod, held.anniversary ?? null),
|
|
146
159
|
spent,
|
|
147
160
|
remaining: currentPeriod === null ? null : Math.max(0, cap.amount - spent),
|
|
148
161
|
};
|
|
@@ -180,14 +193,13 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
180
193
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
181
194
|
rotationGap: rotationGap(card, today),
|
|
182
195
|
caps: capSummaries(catalog, card, held, today),
|
|
196
|
+
milestones: milestoneSummaries(card, held, today),
|
|
183
197
|
chosen: held.chosen
|
|
184
198
|
? {
|
|
185
199
|
period: held.chosen.period,
|
|
186
200
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
187
201
|
// Picks that carry over have no end date.
|
|
188
|
-
until: card.
|
|
189
|
-
? null
|
|
190
|
-
: periodEnd(held.chosen.period),
|
|
202
|
+
until: picksUntil(card, held.chosen.period),
|
|
191
203
|
picks: held.chosen.picks,
|
|
192
204
|
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
193
205
|
choiceName(choice),
|
package/dist/format/cards.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { money, NETWORK_NAMES } from "../engine/text.js";
|
|
1
|
+
import { foreignFeeText, money, NETWORK_NAMES } from "../engine/text.js";
|
|
2
2
|
export function formatCardInfo(card) {
|
|
3
3
|
const fee = card.annualFee === null
|
|
4
4
|
? ''
|
|
@@ -19,7 +19,9 @@ export function formatCardInfo(card) {
|
|
|
19
19
|
? [`activate with the issuer: ${card.activation.join(', ')}`]
|
|
20
20
|
: []),
|
|
21
21
|
...(card.needsAnniversary ? ['cap resets on the card anniversary'] : []),
|
|
22
|
+
...card.milestones.map((m) => `spend bonus: ${m.text} (${m.id})`),
|
|
22
23
|
...(card.onlyAt ? [`works only at ${card.onlyAt.join(', ')}`] : []),
|
|
24
|
+
foreignFeeText(card.foreignTransactionFee).toLowerCase(),
|
|
23
25
|
];
|
|
24
26
|
return [
|
|
25
27
|
`${card.id}: ${card.name} (${NETWORK_NAMES[card.network] ?? card.network}${fee})${card.held ? ' [in wallet]' : ''}`,
|
package/dist/format/wallet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
1
|
+
import { CAP_PERIODS, money, NETWORK_NAMES, shortDate } from "../engine/text.js";
|
|
2
2
|
// "To do now", above the card details; nothing when all's done.
|
|
3
3
|
export function formatChecklist(items) {
|
|
4
4
|
return items.length === 0
|
|
@@ -34,8 +34,11 @@ export function formatWalletSummary(summary) {
|
|
|
34
34
|
if (c.rotationGap !== null)
|
|
35
35
|
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
36
36
|
for (const cap of c.caps)
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
lines.push(cap.remaining === null
|
|
38
|
+
? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
|
|
39
|
+
: ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
|
|
40
|
+
for (const m of c.milestones)
|
|
41
|
+
lines.push(` Spend bonus (${m.id}): ${m.text}`);
|
|
39
42
|
if (c.anniversary !== null)
|
|
40
43
|
lines.push(` Account anniversary: ${c.anniversary}.`);
|
|
41
44
|
const b = c.bonus;
|
|
@@ -53,7 +56,7 @@ export function formatWalletSummary(summary) {
|
|
|
53
56
|
: "Point values: earnrate's defaults.";
|
|
54
57
|
return [...cards, memberships, values].join('\n');
|
|
55
58
|
}
|
|
56
|
-
export function formatBooking(amount, cardId, cap, bonus) {
|
|
59
|
+
export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
|
|
57
60
|
const messages = [];
|
|
58
61
|
if (cap) {
|
|
59
62
|
const left = Math.max(0, cap.capAmount - cap.after);
|
|
@@ -65,5 +68,16 @@ export function formatBooking(amount, cardId, cap, bonus) {
|
|
|
65
68
|
? `Welcome bonus spend met on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)}.`
|
|
66
69
|
: `Welcome bonus on ${cardId}: ${money(bonus.after)} of ${money(bonus.spend)} spent, ${money(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
|
|
67
70
|
}
|
|
71
|
+
// Every milestone counts the same purchases, so one line covers them.
|
|
72
|
+
const first = milestones[0];
|
|
73
|
+
if (first) {
|
|
74
|
+
const reached = milestones.filter((m) => m.before < m.spend && m.after >= m.spend);
|
|
75
|
+
const next = milestones.find((m) => m.after < m.spend);
|
|
76
|
+
messages.push([
|
|
77
|
+
`Spend on ${cardId} this year: ${money(first.after)}.`,
|
|
78
|
+
...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
|
|
79
|
+
...(next ? [`${money(next.spend - next.after)} to its next one.`] : []),
|
|
80
|
+
].join(' '));
|
|
81
|
+
}
|
|
68
82
|
return messages.join(' ');
|
|
69
83
|
}
|
package/dist/import/analyze.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
|
|
2
2
|
import { HALF_CENT } from "../engine/compare.js";
|
|
3
3
|
import { capByKey, categoryMatches } from "../engine/evaluate.js";
|
|
4
|
+
import { milestoneProgress } from "../engine/milestones.js";
|
|
4
5
|
import { periodKey } from "../engine/periods.js";
|
|
5
6
|
import { recommend } from "../engine/recommend.js";
|
|
6
7
|
import { applyBooking, capBooking } from "../engine/record.js";
|
|
@@ -41,8 +42,14 @@ function capPeriodKey(catalog, wallet, booking, date) {
|
|
|
41
42
|
// up partway through the year is accounted for on both sides.
|
|
42
43
|
export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorded = () => false) {
|
|
43
44
|
const sorted = [...entries].sort((a, b) => a.line.date.localeCompare(b.line.date));
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// The earned-versus-best replay leaves spend milestones out: they pay once
|
|
46
|
+
// on the card's total, whichever card is best for each purchase.
|
|
47
|
+
const replay = {
|
|
48
|
+
...wallet,
|
|
49
|
+
cards: wallet.cards.map(({ milestone_usage: _usage, ...c }) => c),
|
|
50
|
+
};
|
|
51
|
+
let actual = replay;
|
|
52
|
+
let simulated = replay;
|
|
46
53
|
let current = wallet;
|
|
47
54
|
const byCard = new Map();
|
|
48
55
|
const missed = new Map();
|
|
@@ -69,8 +76,40 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
69
76
|
entries: [],
|
|
70
77
|
});
|
|
71
78
|
}
|
|
79
|
+
const milestones = [];
|
|
80
|
+
for (const held of wallet.cards) {
|
|
81
|
+
const card = catalog.cards.find((c) => c.id === held.id);
|
|
82
|
+
if (!card)
|
|
83
|
+
continue;
|
|
84
|
+
for (const p of milestoneProgress(card, held, today))
|
|
85
|
+
if (p.period !== null)
|
|
86
|
+
milestones.push({
|
|
87
|
+
cardId: held.id,
|
|
88
|
+
milestoneId: p.milestone.id,
|
|
89
|
+
period: p.period,
|
|
90
|
+
spend: p.milestone.spend,
|
|
91
|
+
before: p.spent,
|
|
92
|
+
after: p.spent,
|
|
93
|
+
entries: [],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
72
96
|
for (const entry of sorted) {
|
|
73
97
|
const { cardId, line } = entry;
|
|
98
|
+
if (!alreadyRecorded(entry, 'milestone')) {
|
|
99
|
+
const held = wallet.cards.find((c) => c.id === cardId);
|
|
100
|
+
for (const m of milestones) {
|
|
101
|
+
if (m.cardId !== cardId)
|
|
102
|
+
continue;
|
|
103
|
+
const card = catalog.cards.find((c) => c.id === cardId);
|
|
104
|
+
const milestone = card?.milestones.find((x) => x.id === m.milestoneId);
|
|
105
|
+
if (!milestone ||
|
|
106
|
+
periodKey(milestone.period, line.date, held?.anniversary ?? null) !==
|
|
107
|
+
m.period)
|
|
108
|
+
continue;
|
|
109
|
+
m.after = roundCents(Math.max(0, m.after + line.amount));
|
|
110
|
+
m.entries.push(entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
74
113
|
const progress = bonuses.get(cardId);
|
|
75
114
|
if (progress &&
|
|
76
115
|
countsTowardBonus(progress, line.date) &&
|
|
@@ -202,5 +241,6 @@ export function analyzeStatement(catalog, wallet, entries, today, alreadyRecorde
|
|
|
202
241
|
outsideCurrentPeriod,
|
|
203
242
|
alreadyImported,
|
|
204
243
|
bonuses: [...bonuses.values()].filter((b) => b.entries.length > 0),
|
|
244
|
+
milestones: milestones.filter((m) => m.entries.length > 0),
|
|
205
245
|
};
|
|
206
246
|
}
|
package/dist/mcp/inputs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ALL_PERSONAL_CARDS } from "../engine/explore.js";
|
|
|
3
3
|
import { profileText, VALUATION_PROFILES } from "../engine/profiles.js";
|
|
4
4
|
import { knownMemberships } from "../engine/setup.js";
|
|
5
5
|
import { IsoDate } from "../schema/common.js";
|
|
6
|
+
import { COUNTRY_CODE } from "../schema/common.js";
|
|
6
7
|
export const Merchant = z
|
|
7
8
|
.string()
|
|
8
9
|
.min(1)
|
|
@@ -12,6 +13,11 @@ export const ChannelInput = z
|
|
|
12
13
|
.optional()
|
|
13
14
|
.describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
|
|
14
15
|
export const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
|
|
16
|
+
export const CountryInput = z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(COUNTRY_CODE, 'Use a two-letter country code, like US or FR.')
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Where the purchase is made, as a two-letter country code (FR, GB, JP). Pass it when the user is paying abroad or in another currency: abroad, cards are ranked after their foreign transaction fees. Default US.');
|
|
15
21
|
export const CardId = z
|
|
16
22
|
.string()
|
|
17
23
|
.min(1)
|
|
@@ -66,6 +72,10 @@ const cardFields = {
|
|
|
66
72
|
.record(z.string(), z.number().nonnegative())
|
|
67
73
|
.optional()
|
|
68
74
|
.describe('Dollars already spent this period on a capped bonus, for a card the user starts tracking partway through: cap key (as list_wallet shows it) → dollars, e.g. {"five-percent-quarterly": 400}. Ask; recorded purchases add to it from then on.'),
|
|
75
|
+
milestoneSpent: z
|
|
76
|
+
.record(z.string(), z.number().nonnegative())
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Dollars already spent on the card this year, for its spend milestones (bonuses after a yearly spending total, as search_cards lists them): milestone id → dollars, e.g. {"spend-15k": 9000}. Every purchase on the card counts, so give each milestone the same total. Ask; recorded purchases add to it from then on.'),
|
|
69
79
|
bonus: z
|
|
70
80
|
.object({
|
|
71
81
|
opened: IsoDate.describe('The day the account opened, YYYY-MM-DD.'),
|