earnrate 0.2.17 → 0.2.18

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.
Files changed (59) hide show
  1. package/README.md +9 -3
  2. package/data/CHANGELOG.md +19 -1
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-business-gold.yaml +1 -0
  5. package/data/cards/amex-platinum.yaml +1 -1
  6. package/data/cards/chase-freedom-flex.yaml +3 -6
  7. package/data/cards/chase-ihg-one-rewards-premier.yaml +2 -0
  8. package/data/cards/chase-united-business.yaml +3 -0
  9. package/data/cards/chase-united-club-business.yaml +5 -0
  10. package/data/cards/citi-aadvantage-business.yaml +5 -0
  11. package/data/cards/citi-aadvantage-executive.yaml +2 -0
  12. package/data/cards/citi-aadvantage-globe.yaml +2 -0
  13. package/data/cards/citi-aadvantage-mileup.yaml +4 -0
  14. package/data/cards/citi-aadvantage-platinum-select.yaml +4 -0
  15. package/data/cards/citi-costco-anywhere-visa-business.yaml +2 -0
  16. package/data/cards/citi-costco-anywhere-visa.yaml +2 -0
  17. package/data/cards/citi-custom-cash.yaml +8 -10
  18. package/data/cards/citi-double-cash.yaml +1 -0
  19. package/data/cards/citi-my-best-buy-visa.yaml +1 -0
  20. package/data/cards/citi-strata-elite.yaml +2 -0
  21. package/data/cards/citi-strata-premier.yaml +2 -0
  22. package/data/cards/td-bank-nordstrom-credit-card.yaml +1 -0
  23. package/data/cards/td-bank-nordstrom-visa.yaml +1 -0
  24. package/data/changes.yaml +2840 -0
  25. package/dist/cli/offers.js +21 -5
  26. package/dist/cli/run.js +14 -3
  27. package/dist/cli/wallet.js +28 -3
  28. package/dist/data/change-integrity.js +105 -0
  29. package/dist/data/file-store.js +5 -2
  30. package/dist/data/integrity.js +7 -0
  31. package/dist/data/load.js +5 -0
  32. package/dist/data/store.js +4 -1
  33. package/dist/data/wallet-edit.js +16 -5
  34. package/dist/engine/changes.js +124 -0
  35. package/dist/engine/checklist.js +18 -3
  36. package/dist/engine/explore.js +28 -9
  37. package/dist/engine/offers.js +52 -3
  38. package/dist/engine/periods.js +7 -0
  39. package/dist/engine/renewal.js +101 -0
  40. package/dist/engine/rule-text.js +2 -7
  41. package/dist/engine/setup.js +6 -3
  42. package/dist/engine/simulate.js +14 -7
  43. package/dist/engine/text.js +64 -0
  44. package/dist/engine/wallet-change.js +16 -8
  45. package/dist/engine/wallet-summary.js +4 -0
  46. package/dist/format/card-text.js +9 -2
  47. package/dist/format/offers.js +5 -3
  48. package/dist/format/wallet-yaml.js +3 -0
  49. package/dist/format/wallet.js +6 -1
  50. package/dist/mcp/context.js +11 -3
  51. package/dist/mcp/inputs.js +3 -1
  52. package/dist/mcp/prompts.js +3 -3
  53. package/dist/mcp/schemas.js +24 -0
  54. package/dist/mcp/tools/answers.js +42 -24
  55. package/dist/mcp/tools/wallet.js +28 -8
  56. package/dist/schema/card.js +7 -0
  57. package/dist/schema/changes.js +73 -0
  58. package/dist/schema/wallet.js +5 -1
  59. package/package.json +1 -1
@@ -96,6 +96,7 @@ const RecommendationSchema = z.object({
96
96
  export const WalletSummarySchema = z.object({
97
97
  memberships: z.array(z.string()),
98
98
  membershipNames: z.array(z.string()),
99
+ skippedIssuers: z.array(z.object({ id: z.string(), name: z.string() })),
99
100
  pointValues: z.array(z.object({
100
101
  currency: z.string(),
101
102
  name: z.string(),
@@ -115,6 +116,7 @@ export const WalletSummarySchema = z.object({
115
116
  'intro',
116
117
  'credit',
117
118
  'enroll',
119
+ 'renewal',
118
120
  ]),
119
121
  cardId: z.string(),
120
122
  ref: z.string().optional(),
@@ -290,11 +292,24 @@ export const OfferComparisonSchema = z.object({
290
292
  // if all were used. Not ranked on.
291
293
  credits: z.number(),
292
294
  firstYearValueIfCreditsUsed: z.number(),
295
+ // On the user's spending beside their cards; null for a card they hold.
296
+ plan: z
297
+ .object({
298
+ extraRewards: z.number(),
299
+ minimumSpend: z
300
+ .object({ reachable: z.boolean(), weeks: z.number() })
301
+ .nullable(),
302
+ yearOne: z.number(),
303
+ yearTwo: z.number(),
304
+ twoYears: z.number(),
305
+ })
306
+ .nullable(),
293
307
  held: z.boolean(),
294
308
  lastVerified: z.string(),
295
309
  verifiedBy: z.string(),
296
310
  })),
297
311
  withoutOffer: z.array(z.string()),
312
+ typical: z.boolean(),
298
313
  notes: z.array(z.string()),
299
314
  });
300
315
  export const CardSearchSchema = z.object({
@@ -320,6 +335,7 @@ export const CardSearchSchema = z.object({
320
335
  'calendar_year',
321
336
  'until_changed',
322
337
  ]),
338
+ automatic: z.boolean(),
323
339
  options: z.array(z.string()),
324
340
  default: z.array(z.string()).nullable(),
325
341
  })),
@@ -406,6 +422,7 @@ export const ExplorationSchema = z.object({
406
422
  choice: z.string(),
407
423
  options: z.array(z.string()),
408
424
  period: z.string(),
425
+ automatic: z.boolean(),
409
426
  })
410
427
  .nullable(),
411
428
  activation: z.string().nullable(),
@@ -427,6 +444,13 @@ export const ExplorationSchema = z.object({
427
444
  excluded: z.array(ExcludedCardSchema),
428
445
  assumptions: z.array(z.string()),
429
446
  warnings: z.array(z.string()),
447
+ // Ways of paying that would put a card at the top, when payment is unknown.
448
+ ifPaying: z.array(z.object({
449
+ cardId: z.string(),
450
+ cardName: z.string(),
451
+ payment: z.string(),
452
+ earn: z.string(),
453
+ })),
430
454
  });
431
455
  const SimulationSchema = z.object({
432
456
  cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
@@ -31,7 +31,7 @@ const TOP = 10;
31
31
  // named in the conversation, or a saved wallet if there is one.
32
32
  export function registerAnswerTools(server, deps, ctx, inputs) {
33
33
  const { catalog } = deps;
34
- const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, } = inputs;
34
+ const { CategoryInput, CatalogCategory, NamedCards, NamedMemberships, OfferFilters, CatalogFilters, SkippedIssuers, } = inputs;
35
35
  server.registerTool('best_card_for_purchase', {
36
36
  title: 'Best card for a purchase',
37
37
  description: "Which credit card earns the most rewards for a purchase, and why. With no cards given and no saved wallet, it ranks every card earnrate knows (in `allCards`): a complete answer for anyone. With cards named in the conversation (`cards`) or a saved wallet, it picks the best of the user's own: `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout, with every usable card ranked with reasons, cards the merchant won't take, assumptions, warnings, and when a different point valuation would change the pick. For a business earnrate may not know, like a local restaurant, pass `category` when you can tell what kind of business it is. When the user is paying outside the U.S. or in another currency, pass `country`: abroad, each card is ranked after its foreign transaction fee. Pass `payment` only when the user says how they're paying, like Apple Pay or PayPal checkout. A statement credit (a card's `credit`, like Amex Platinum's lululemon credit) counts toward the pick only once it's enrolled in the wallet and has dollars left this period, and only when the amount is given; say its dollars apart from the % back. With no cards, `allCards.credits` lists cards with a credit at the store; present those first. Never needs or returns card numbers.",
@@ -155,9 +155,34 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
155
155
  structuredContent: m,
156
156
  };
157
157
  });
158
+ const SpendingLines = z
159
+ .array(z.object({
160
+ merchant: Merchant.optional(),
161
+ category: CatalogCategory.optional(),
162
+ monthly: z.number().positive().describe('US dollars a month.'),
163
+ }))
164
+ .optional()
165
+ .describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered.");
166
+ // Spending lines as given, or typical amounts; a string is why they can't
167
+ // be used.
168
+ const spendingFrom = (given) => {
169
+ if (given.some((l) => l.merchant === undefined && l.category === undefined))
170
+ return 'Each spending line needs a merchant or a category.';
171
+ const lines = given.length === 0
172
+ ? typicalSpendLines(catalog)
173
+ : resolveSpendLines(catalog, given.map((l) => ({
174
+ merchant: l.merchant ?? null,
175
+ category: l.merchant === undefined ? (l.category ?? null) : null,
176
+ monthly: l.monthly,
177
+ })));
178
+ const unknown = lines.filter((l) => l.merchant.source === 'none');
179
+ if (unknown.length > 0)
180
+ return `earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`;
181
+ return { lines, typical: given.length === 0 };
182
+ };
158
183
  server.registerTool('compare_welcome_offers', {
159
184
  title: 'Compare welcome offers',
160
- description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee and any membership the card needs that the user doesn't have. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
185
+ description: "Ranks cards the user doesn't have by what they'd be worth over the first two years: what each adds to the user's cards (or, with none, earns) on their spending, plus the public welcome (sign-up) bonus in year one if its minimum spend fits in the window, less each year's annual fee and any membership it needs. Each offer also shows the bonus alone (first-year value), year one and year two, and how many weeks of spending the minimum takes. Offers the issuer rules out because of a card in the user's wallet are marked likely not eligible and listed last; issuers the user can't get cards from (skippedIssuers, or saved with set_memberships) are left out. Use it when the user asks which card to get next or which has the best sign-up bonus. Spending is optional (typical amounts otherwise). It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. Never suggest applying through a link.",
161
186
  inputSchema: z.object({
162
187
  ...OfferFilters,
163
188
  valuation: CatalogFilters.valuation,
@@ -167,6 +192,8 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
167
192
  .describe('Include cards already in the wallet.'),
168
193
  cards: NamedCards,
169
194
  memberships: NamedMemberships,
195
+ spending: SpendingLines,
196
+ skippedIssuers: SkippedIssuers.optional().describe("Issuers the user says they can't or won't get a new card from (for example Chase, over its 5/24 rule), for this answer. Replaces the saved list. Never assume it."),
170
197
  }),
171
198
  outputSchema: OfferComparisonSchema,
172
199
  annotations: {
@@ -180,15 +207,21 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
180
207
  if (typeof wallet === 'string')
181
208
  return fail(wallet);
182
209
  const found = wallet ?? { ...EMPTY_WALLET, ...(await ctx.saved()) };
183
- const base = args.memberships
210
+ const spending = spendingFrom(args.spending ?? []);
211
+ if (typeof spending === 'string')
212
+ return fail(spending);
213
+ const withMembers = args.memberships
184
214
  ? { ...found, memberships: args.memberships }
185
215
  : found;
216
+ const base = args.skippedIssuers
217
+ ? { ...withMembers, skipped_issuers: args.skippedIssuers }
218
+ : withMembers;
186
219
  const comparison = compareOffers(catalog, args.valuation ? withValuationProfile(base, args.valuation) : base, {
187
220
  kind: args.kind ?? 'any',
188
221
  noAnnualFee: args.noAnnualFee ?? false,
189
222
  business: args.business ?? false,
190
223
  includeHeld: args.includeHeld ?? false,
191
- }, deps.today());
224
+ }, deps.today(), spending);
192
225
  const trimmed = {
193
226
  ...comparison,
194
227
  // Offers ruled out by a card the user holds sort last; keep them in
@@ -260,14 +293,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
260
293
  title: 'Simulate a year of spending',
261
294
  description: 'Spends monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees, and minus a membership a card needs (a Costco membership) unless the user has it or spends at Costco. Spending is optional: without it, typical U.S. household amounts are used and the answer says so. With `cards` (named in the conversation) or compareToWallet (the saved wallet), it ranks the cards that would add the most to those cards instead. Use it for "which card fits my spending?" or "is it worth adding a card?". It leaves out welcome bonuses.',
262
295
  inputSchema: z.object({
263
- spending: z
264
- .array(z.object({
265
- merchant: Merchant.optional(),
266
- category: CatalogCategory.optional(),
267
- monthly: z.number().positive().describe('US dollars a month.'),
268
- }))
269
- .optional()
270
- .describe("Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco). Optional: leave out to use typical amounts, and don't press the user for numbers they haven't offered."),
296
+ spending: SpendingLines,
271
297
  cards: NamedCards,
272
298
  compareToWallet: z
273
299
  .boolean()
@@ -284,18 +310,10 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
284
310
  },
285
311
  }, async (args) => {
286
312
  const given = args.spending ?? [];
287
- if (given.some((l) => l.merchant === undefined && l.category === undefined))
288
- return fail('Each spending line needs a merchant or a category.');
289
- const lines = given.length === 0
290
- ? typicalSpendLines(catalog)
291
- : resolveSpendLines(catalog, given.map((l) => ({
292
- merchant: l.merchant ?? null,
293
- category: l.merchant === undefined ? (l.category ?? null) : null,
294
- monthly: l.monthly,
295
- })));
296
- const unknown = lines.filter((l) => l.merchant.source === 'none');
297
- if (unknown.length > 0)
298
- return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
313
+ const spending = spendingFrom(given);
314
+ if (typeof spending === 'string')
315
+ return fail(spending);
316
+ const { lines } = spending;
299
317
  const notes = given.length === 0 ? [TYPICAL_SPEND_NOTE] : [];
300
318
  const options = {
301
319
  ...settings(args, await ctx.saved()),
@@ -7,7 +7,7 @@ import { optionName } from "../../engine/rule-text.js";
7
7
  import { currencyChanges } from "../../engine/wallet-change.js";
8
8
  import { walletSummary } from "../../engine/wallet-summary.js";
9
9
  import { checklist } from "../../engine/checklist.js";
10
- import { cardText } from "../../format/card-text.js";
10
+ import { cardText, issuerName } from "../../format/card-text.js";
11
11
  import { formatBooking, formatChecklist, formatWalletSummary, } from "../../format/wallet.js";
12
12
  import { fail } from "../context.js";
13
13
  import { CardChange, CardId, cardInput, ChannelInput, DateInput, Merchant, } from "../inputs.js";
@@ -187,8 +187,11 @@ export function walletHandlers(deps, ctx, inputs) {
187
187
  run: async (args) => {
188
188
  const what = changes(catalog, args);
189
189
  const card = catalog.cards.find((c) => c.id === args.cardId);
190
- const unused = args.anniversary !== undefined && card && !needsAnniversary(card)
191
- ? " None of its caps reset on the account anniversary, so earnrate doesn't use it."
190
+ const unused = args.anniversary !== undefined &&
191
+ card &&
192
+ !needsAnniversary(card) &&
193
+ (card.annual_fee ?? 0) === 0
194
+ ? " It has no annual fee and none of its caps reset on the account anniversary, so earnrate doesn't use it."
192
195
  : '';
193
196
  return ctx.changed(await store.updateCard(cardInput(args), deps.today()), `Updated ${ctx.cardName(args.cardId)} (${args.cardId})${what.length > 0 ? `: ${what.join('; ')}` : ''}.${unused}`);
194
197
  },
@@ -198,10 +201,27 @@ export function walletHandlers(deps, ctx, inputs) {
198
201
  input: RemoveInput,
199
202
  run: async ({ cardId }) => ctx.changed(await store.removeCard(cardId), `Removed ${ctx.cardName(cardId)} (${cardId}).`, { notes: false }),
200
203
  };
201
- const MembershipsInput = z.object({ memberships: inputs.AllMemberships });
204
+ const MembershipsInput = z.object({
205
+ memberships: inputs.AllMemberships.optional(),
206
+ skippedIssuers: inputs.SkippedIssuers.optional().describe("Issuers the user says they can't or won't get a new card from (for example Chase, over its 5/24 rule): left out of compare_welcome_offers and of the cards that would add the most. Replaces the saved list; [] clears it. Ask; never assume."),
207
+ });
202
208
  const setMemberships = {
203
209
  input: MembershipsInput,
204
- run: async ({ memberships }) => {
210
+ run: async ({ memberships, skippedIssuers }) => {
211
+ if (memberships === undefined && skippedIssuers === undefined)
212
+ return fail('Give memberships, skippedIssuers or both.');
213
+ let skipText = '';
214
+ if (skippedIssuers !== undefined) {
215
+ const skipped = await store.setSkippedIssuers(skippedIssuers);
216
+ skipText =
217
+ skippedIssuers.length > 0
218
+ ? `Next-card answers now leave out ${listed(skippedIssuers.map((i) => issuerName(catalog, i)))}.`
219
+ : 'Next-card answers include every issuer again.';
220
+ if (memberships === undefined || !skipped.ok)
221
+ return ctx.changed(skipped, skipText, { notes: false });
222
+ }
223
+ if (memberships === undefined)
224
+ return fail('Give memberships.');
205
225
  const result = await store.setMemberships(memberships);
206
226
  const used = new Set((result.ok ? result.wallet.cards : []).flatMap((held) => {
207
227
  const card = catalog.cards.find((c) => c.id === held.id);
@@ -210,7 +230,7 @@ export function walletHandlers(deps, ctx, inputs) {
210
230
  const unused = memberships.filter((m) => !used.has(m));
211
231
  return ctx.changed(result, `${memberships.length > 0
212
232
  ? `Memberships set to ${memberships.map((m) => `${membershipName(catalog, m)} (${m})`).join(', ')}.`
213
- : 'Memberships cleared.'}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
233
+ : 'Memberships cleared.'}${skipText === '' ? '' : ` ${skipText}`}${unused.length > 0 ? ` ${listed(unused.map((m) => membershipName(catalog, m)))} ${unused.length === 1 ? "doesn't" : "don't"} change any saved card's rates yet; kept for cards that use ${unused.length === 1 ? 'it' : 'them'}.` : ''}`, { notes: false });
214
234
  },
215
235
  };
216
236
  const PointValuesInput = z.object({
@@ -259,7 +279,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
259
279
  const tools = walletHandlers(deps, ctx, inputs);
260
280
  server.registerTool('list_wallet', {
261
281
  title: 'List wallet',
262
- description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
282
+ description: `Starts with a to-do list for the user's cards right now (bonuses to activate and their deadlines, categories to pick or confirm, caps nearly used up, welcome bonuses to finish and the weekly pace, yearly spend bonuses at least 75% of the way, statement credits to enroll in or use before they reset, and a fee card's renewal check in the month before its anniversary: whether it still pays for itself at typical spending); use it when the user asks what to do with their cards this quarter or what they're forgetting. Then lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, progress toward yearly spend bonuses, statement credits (enrolled or not, and what's left this period), which stores a store card works at, when each card's data was last verified, the point values the user saved, and (for the hosted wallet) when it was started. ${ctx.walletUse}`,
263
283
  outputSchema: WalletSummarySchema,
264
284
  annotations: {
265
285
  title: 'List wallet',
@@ -319,7 +339,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
319
339
  }, (args) => tools.remove_card.run(args));
320
340
  server.registerTool('set_memberships', {
321
341
  title: 'Set memberships',
322
- description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. ${ctx.walletUse}`,
342
+ description: `Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier, and costco for a Costco membership (so spending answers don't count its cost against Costco Anywhere). Replaces the whole list, so include the ones to keep. It saves them even with no cards saved, and every answer then uses them. With skippedIssuers, it also saves the issuers the user can't or won't get a new card from (Chase over its 5/24 rule), which next-card answers leave out; either can be given alone. ${ctx.walletUse}`,
323
343
  inputSchema: tools.set_memberships.input,
324
344
  outputSchema: WalletChangeSchema,
325
345
  annotations: {
@@ -129,6 +129,10 @@ export const ChoiceSchema = z.strictObject({
129
129
  // `until_changed`: the pick carries over until the cardholder changes it,
130
130
  // so a pick made in an earlier period is still current.
131
131
  period: z.enum(['month', 'quarter', 'calendar_year', 'until_changed']),
132
+ // The issuer applies the bonus to the options with the most spending each
133
+ // period (Citi Custom Cash). A wallet's pick is the one the cardholder
134
+ // expects, and wording never says the cardholder picks.
135
+ automatic: z.literal(true).optional(),
132
136
  // Option ids that apply when the wallet hasn't picked, e.g. Bilt's dining.
133
137
  default: z.array(KebabId).min(1).optional(),
134
138
  // Option id → what that option covers: a list of categories; or, when the
@@ -254,6 +258,9 @@ export const CardSchema = z
254
258
  rule: z.string().min(1),
255
259
  url: z.url(),
256
260
  quote: z.string().min(1),
261
+ // Set only after seeing the quote in a popup the page builds on
262
+ // click from its embedded data; other quotes must be in its text.
263
+ in_popup: z.literal(true).optional(),
257
264
  }))
258
265
  .default([]),
259
266
  // Known gaps in this file, surfaced as warnings.
@@ -0,0 +1,73 @@
1
+ import { z } from 'zod';
2
+ import { CategoryId, CurrencyId, IsoDate, KebabId } from "./common.js";
3
+ // Only `issuer` means the issuer changed its terms, and only after earnrate
4
+ // had recorded the old value correctly. A term earnrate starts recording
5
+ // late is an `addition`, so earnrate's own work never reads as a rate cut.
6
+ export const ChangeCause = z.enum([
7
+ 'issuer',
8
+ 'correction',
9
+ 'addition',
10
+ 'wording',
11
+ 'quote',
12
+ 'review',
13
+ ]);
14
+ export const CHANGE_SUBJECTS = [
15
+ 'cards',
16
+ 'merchants',
17
+ 'categories',
18
+ 'currencies',
19
+ 'memberships',
20
+ 'issuers',
21
+ ];
22
+ export const DATA_VERSION = /^(\d{4})\.(\d{2})\.(\d{2})(\.\d+)?$/;
23
+ const ids = z.array(KebabId).min(1);
24
+ export const ChangeSchema = z
25
+ .strictObject({
26
+ cause: ChangeCause,
27
+ card: KebabId.optional(),
28
+ cards: ids.optional(),
29
+ merchants: ids.optional(),
30
+ categories: z.array(CategoryId).min(1).optional(),
31
+ currencies: z.array(CurrencyId).min(1).optional(),
32
+ memberships: ids.optional(),
33
+ issuers: ids.optional(),
34
+ // A path in each subject: `welcome_offer`, `rules.amazon`,
35
+ // `credits.instacart.valid_to`. List items are found by their id.
36
+ // Without one, the change is to the whole subject (a new card).
37
+ field: z
38
+ .string()
39
+ .regex(/^[a-z_]+(\.[a-z0-9_-]+)*$/)
40
+ .optional(),
41
+ // The field's value before and after, as written in the data file.
42
+ // `was: null` means it didn't exist; `now: null` means it was removed.
43
+ was: z.unknown().optional(),
44
+ now: z.unknown().optional(),
45
+ summary: z.string().min(1).optional(),
46
+ source: z.url().optional(),
47
+ // When the change reached the data, if before its release's date.
48
+ date: IsoDate.optional(),
49
+ // Issuer changes: when the source check or a person first saw it, and
50
+ // when the issuer says it took effect. Left out when not known.
51
+ detected: IsoDate.optional(),
52
+ effective: IsoDate.optional(),
53
+ })
54
+ .transform(({ card, ...change }) => card === undefined ? change : { ...change, cards: [card] })
55
+ .refine((c) => CHANGE_SUBJECTS.filter((s) => c[s] !== undefined).length === 1, 'a change names one kind of subject: card(s), merchants, categories, currencies, memberships or issuers')
56
+ .refine((c) => c.cause !== 'issuer' ||
57
+ (c.cards?.length === 1 &&
58
+ c.field !== undefined &&
59
+ c.was !== undefined &&
60
+ c.now !== undefined &&
61
+ c.source !== undefined), 'an issuer change needs one card, a field, was, now and source')
62
+ .refine((c) => c.cause === 'issuer' || c.summary !== undefined, "earnrate's own changes need a summary")
63
+ .refine((c) => c.cause === 'issuer' ||
64
+ (c.detected === undefined && c.effective === undefined), 'detected and effective are for issuer changes');
65
+ export const ReleaseSchema = z
66
+ .strictObject({
67
+ version: z.string().regex(DATA_VERSION),
68
+ date: IsoDate,
69
+ changes: z.array(ChangeSchema).min(1),
70
+ })
71
+ .refine((r) => r.date === r.version.slice(0, 10).replaceAll('.', '-'), "a release's date is its version's date");
72
+ // Newest release first, like data/CHANGELOG.md.
73
+ export const ChangesFileSchema = z.array(ReleaseSchema).min(1);
@@ -27,7 +27,8 @@ export const WalletCardSchema = z.strictObject({
27
27
  .min(1)
28
28
  .refine((t) => !labelHasCardNumber(t), CARD_NUMBER_LABEL)
29
29
  .optional(),
30
- // MM-DD, needed only for cardmember_year caps.
30
+ // MM-DD: for cardmember_year caps and a fee card's renewal check. The
31
+ // opening date stands in for the renewal check when this isn't set.
31
32
  anniversary: z
32
33
  .string()
33
34
  .regex(/^\d{2}-\d{2}$/)
@@ -74,5 +75,8 @@ export const WalletSchema = z.strictObject({
74
75
  version: z.number().int().positive().optional(),
75
76
  valuations: z.record(CurrencyId, Valuation).default({}),
76
77
  memberships: z.array(KebabId).default([]),
78
+ // Issuers whose new cards the person can't or won't get (Chase over
79
+ // 5/24): left out of next-card answers. Never assumed.
80
+ skipped_issuers: z.array(KebabId).optional(),
77
81
  cards: z.array(WalletCardSchema).default([]),
78
82
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",