rightcard-mcp 0.1.0

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 ADDED
@@ -0,0 +1,54 @@
1
+ # RightCard MCP
2
+
3
+ The card-selection oracle for agents: **which of your credit cards should pay here?**
4
+ Answers come from RightCard's bank-published, two-source-verified reward data — the same
5
+ data the [RightCard iOS app](https://apps.apple.com/app/id6756834989) uses.
6
+
7
+ - **No bank login. No account. Nothing stored.** You pass card ids and a store name; you get an answer.
8
+ - Honest by construction: merchant-code caveats (Costco is a warehouse club, so the grocery bonus
9
+ won't post), rotating quarters with activation flags, ties named, points valued per program.
10
+ - Cash value by default; `valuation: "points"` for conservative travel values.
11
+
12
+ ## Use it
13
+
14
+ Hosted (stateless Streamable HTTP):
15
+
16
+ ```
17
+ https://mcp.rightcard.ai/mcp
18
+ ```
19
+
20
+ Local (stdio, nothing leaves your machine except a read of the public catalog):
21
+
22
+ ```
23
+ npx rightcard-mcp
24
+ ```
25
+
26
+ Claude Desktop / Claude Code / Cursor config:
27
+
28
+ ```json
29
+ { "mcpServers": { "rightcard": { "command": "npx", "args": ["-y", "rightcard-mcp"] } } }
30
+ ```
31
+
32
+ ## Tools
33
+
34
+ | tool | what it answers |
35
+ |---|---|
36
+ | `best_card` | which of the given cards earns most at a store or category, today, with caveats |
37
+ | `lookup_merchant` | how a store is coded (category, merchant type, MCC-trap caveat) |
38
+ | `search_cards` | find card ids by name/issuer; verified first, unverified flagged |
39
+ | `card` | one card's verified rates, rotating windows, choose-your-category spec |
40
+ | `rotating_calendar` | live + upcoming rotating windows and permanent merchant benefits |
41
+
42
+ Try: "I have the Amex Gold, Freedom Flex and Citi Double Cash. Which one at Costco?"
43
+
44
+ ## Parity with the app
45
+
46
+ `src/engine.ts` is a port of the iOS app's Swift engine. `fixtures/engine_golden.json` holds
47
+ 3,100+ answers produced by the Swift code over the real catalog and merchant directory;
48
+ `npm test` fails if the port disagrees on any of them. Offers (a user's personal bank offers)
49
+ are deliberately not part of this server — they live on the phone.
50
+
51
+ ## Privacy
52
+
53
+ The hosted server logs request counts only. No identifiers, no cookies, no storage.
54
+ Full policy: https://rightcard.ai/privacy
@@ -0,0 +1,16 @@
1
+ import type { CreditCard, SpendCategory } from "./model.js";
2
+ export interface ConfigurableCardSpec {
3
+ key: string;
4
+ displayName: string;
5
+ bonusRate: number;
6
+ slots: number;
7
+ eligible: SpendCategory[];
8
+ note: string;
9
+ matchNeedles: string[];
10
+ multiCardMax: number;
11
+ }
12
+ export declare const CONFIGURABLE_SPECS: ConfigurableCardSpec[];
13
+ /** ConfigurableCardCatalog.normalize: lowercase, "+" → "plus", strip non-alphanumerics. */
14
+ export declare function normalizeConfigurable(s: string): string;
15
+ export declare function specFor(card: CreditCard): ConfigurableCardSpec | null;
16
+ export declare const isConfigurable: (card: CreditCard) => boolean;
@@ -0,0 +1,47 @@
1
+ export const CONFIGURABLE_SPECS = [
2
+ {
3
+ key: "amex_business_gold", displayName: "Amex Business Gold", bonusRate: 4, slots: 2,
4
+ eligible: ["dining", "gas", "travel"],
5
+ note: "4× on the 2 categories your business spends the most on each billing cycle — from restaurants, gas, airfare, advertising, shipping & select tech — on up to $150,000/year (1× after). Of the categories RightCard tracks, pick the up-to-2 you expect: restaurants, gas, or airfare.",
6
+ matchNeedles: ["businessgold"], multiCardMax: 1,
7
+ },
8
+ {
9
+ key: "bofa_business_customized", displayName: "BofA Business Advantage Customized Cash", bonusRate: 3, slots: 1,
10
+ eligible: ["gas", "travel"],
11
+ note: "3% in your chosen business category (plus an automatic 2% at restaurants), on up to $50,000 in combined choice + dining purchases each year (1% after). Of the categories RightCard tracks, the choice is gas or travel — default gas.",
12
+ matchNeedles: ["businessadvantagecustomized"], multiCardMax: 1,
13
+ },
14
+ {
15
+ key: "bilt_obsidian", displayName: "Bilt Obsidian Card", bonusRate: 3, slots: 1,
16
+ eligible: ["dining", "grocery"],
17
+ note: "3× on your choice of dining OR grocery, set for the calendar year — pick the one you spend more on (grocery counts up to $25,000/year). Everything else earns 1×.",
18
+ matchNeedles: ["obsidian"], multiCardMax: 1,
19
+ },
20
+ {
21
+ key: "citi_custom_cash", displayName: "Citi Custom Cash", bonusRate: 5, slots: 1,
22
+ eligible: ["dining", "grocery", "gas", "travel", "drugstore", "transit", "streaming"],
23
+ note: "5% on your top eligible category, up to $500 each billing cycle (1% after). Citi applies it to your highest-spend category automatically — set the one you expect so RightCard recommends it.",
24
+ matchNeedles: ["customcash"], multiCardMax: 3,
25
+ },
26
+ {
27
+ key: "bofa_customized_cash", displayName: "Bank of America Customized Cash Rewards", bonusRate: 3, slots: 1,
28
+ eligible: ["gas", "online", "dining", "travel", "drugstore"],
29
+ note: "3% in your chosen category (plus 2% at grocery & wholesale clubs), on up to $2,500 in combined choice + grocery purchases each quarter (1% after).",
30
+ matchNeedles: ["customized"], multiCardMax: 3,
31
+ },
32
+ {
33
+ key: "venmo_credit_card", displayName: "Venmo Credit Card", bonusRate: 3, slots: 1,
34
+ eligible: ["dining", "grocery", "gas", "travel", "transit"],
35
+ note: "3% back on the category you spend most in each month (2% on your second, 1% everywhere else). Venmo applies the tiers automatically from your actual spending — set the category you expect to top so RightCard recommends it there. The 2% tier isn't modeled.",
36
+ matchNeedles: ["venmo"], multiCardMax: 1,
37
+ },
38
+ ];
39
+ /** ConfigurableCardCatalog.normalize: lowercase, "+" → "plus", strip non-alphanumerics. */
40
+ export function normalizeConfigurable(s) {
41
+ return s.toLowerCase().replace(/\+/g, "plus").replace(/[^\p{L}\p{N}]/gu, "");
42
+ }
43
+ export function specFor(card) {
44
+ const hay = normalizeConfigurable(card.id) + normalizeConfigurable(card.displayName);
45
+ return CONFIGURABLE_SPECS.find((spec) => spec.matchNeedles.some((n) => hay.includes(n))) ?? null;
46
+ }
47
+ export const isConfigurable = (card) => specFor(card) !== null;
package/dist/data.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { type CreditCard, type Merchant, type Override } from "./model.js";
2
+ import { type IndexEntry } from "./search.js";
3
+ export declare const SUPABASE_URL = "https://janvnoyczokisiepefol.supabase.co/rest/v1";
4
+ export declare const SUPABASE_ANON: string;
5
+ export interface Catalog {
6
+ cards: CreditCard[];
7
+ cardsById: Map<string, CreditCard>;
8
+ overrides: Override[];
9
+ merchants: Merchant[];
10
+ index: IndexEntry[];
11
+ loadedAt: number;
12
+ }
13
+ export declare function loadCatalog(fetchImpl?: typeof fetch): Promise<Catalog>;
14
+ /** One-hour edge/process cache; a failed refresh keeps serving the last good catalog. */
15
+ export declare function catalogCache(fetchImpl?: typeof fetch, ttlMs?: number): () => Promise<Catalog>;
package/dist/data.js ADDED
@@ -0,0 +1,54 @@
1
+ // Public-tier data access. ANON key only (publishable, RLS-gated, read-only) — the same
2
+ // key the iOS app ships. No private tables, no auth, no user data, ever.
3
+ import { cardFromRow, merchantFromRow, overrideFromRow } from "./model.js";
4
+ import { buildIndex } from "./search.js";
5
+ export const SUPABASE_URL = "https://janvnoyczokisiepefol.supabase.co/rest/v1";
6
+ export const SUPABASE_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
7
+ "eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImphbnZub3ljem9raXNpZXBlZm9sIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODAxOTc5NDUsImV4cCI6MjA5NTc3Mzk0NX0." +
8
+ "WdNuhqWft11jh5btv0tvbAA2IUIgpbNTPKaf_YluvwM";
9
+ async function getAll(path, fetchImpl) {
10
+ const out = [];
11
+ const page = 1000;
12
+ for (let off = 0; off < 50000; off += page) {
13
+ const sep = path.includes("?") ? "&" : "?";
14
+ const res = await fetchImpl(`${SUPABASE_URL}/${path}${sep}limit=${page}&offset=${off}`, {
15
+ headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${SUPABASE_ANON}` },
16
+ });
17
+ if (!res.ok)
18
+ throw new Error(`supabase ${path}: HTTP ${res.status}`);
19
+ const rows = (await res.json());
20
+ out.push(...rows);
21
+ if (rows.length < page)
22
+ break;
23
+ }
24
+ return out;
25
+ }
26
+ export async function loadCatalog(fetchImpl = fetch) {
27
+ const [cardRows, overrideRows, merchantRows] = await Promise.all([
28
+ getAll("cards?select=id,name,issuer,reward_currency,base_rate,standard_multipliers,annual_fee,verify_status&country=eq.US&order=id", fetchImpl),
29
+ getAll("active_overrides?select=*&order=id", fetchImpl),
30
+ getAll("merchants?select=id,display_name,category,sub_key,merchant_type,is_online,aliases&country=eq.US&order=id", fetchImpl),
31
+ ]);
32
+ const cards = cardRows.map(cardFromRow);
33
+ const merchants = merchantRows.map(merchantFromRow);
34
+ return {
35
+ cards, cardsById: new Map(cards.map((c) => [c.id, c])), overrides: overrideRows.map(overrideFromRow),
36
+ merchants, index: buildIndex(merchants), loadedAt: Date.now(),
37
+ };
38
+ }
39
+ /** One-hour edge/process cache; a failed refresh keeps serving the last good catalog. */
40
+ export function catalogCache(fetchImpl = fetch, ttlMs = 60 * 60 * 1000) {
41
+ let current = null;
42
+ let inflight = null;
43
+ return async () => {
44
+ if (current && Date.now() - current.loadedAt < ttlMs)
45
+ return current;
46
+ if (!inflight) {
47
+ inflight = loadCatalog(fetchImpl).then((c) => { current = c; return c; })
48
+ .catch((e) => { if (current)
49
+ return current; throw e; })
50
+ .finally(() => { inflight = null; });
51
+ }
52
+ return inflight;
53
+ };
54
+ }
@@ -0,0 +1,40 @@
1
+ import { type CreditCard, type Override, type SpendCategory } from "./model.js";
2
+ import { type Valuation } from "./valuation.js";
3
+ export interface SubcategoryBest {
4
+ label: string;
5
+ cardName: string;
6
+ rate: string;
7
+ }
8
+ export interface Recommendation {
9
+ cardId: string;
10
+ cardName: string;
11
+ rateDisplay: string;
12
+ valueCaption: string;
13
+ reason: string;
14
+ breakdownLines: string[];
15
+ loggableRate: number | null;
16
+ valueMath: string | null;
17
+ valueMathShort: string | null;
18
+ valuationBasis: string | null;
19
+ valuationMode: Valuation["mode"];
20
+ tieNote: string | null;
21
+ portalNote: string | null;
22
+ subcategoryBreakdown: SubcategoryBest[];
23
+ /** set by the caller from the directory (exclusionCaveat) */
24
+ caveat?: string | null;
25
+ }
26
+ export interface RecommendInput {
27
+ cards: CreditCard[];
28
+ category: SpendCategory;
29
+ subKey?: string | null;
30
+ merchantName?: string | null;
31
+ overrides: Override[];
32
+ valuation: Valuation;
33
+ cardConfigs?: Record<string, Set<SpendCategory>>;
34
+ now: Date;
35
+ strictMerchantMatch?: boolean;
36
+ }
37
+ export declare function formatPercent(v: number): string;
38
+ export declare function formatMultiplier(v: number): string;
39
+ /** MerchantYieldEngine.recommend — base-rate mode (offers: []) */
40
+ export declare function recommend(input: RecommendInput): Recommendation | null;
package/dist/engine.js ADDED
@@ -0,0 +1,264 @@
1
+ // Mirrors MerchantYieldEngine (BASE-RATE mode only — offers live on the phone, never here).
2
+ import { CATEGORY_DISPLAY, isPermanentMerchantBenefit, multiplier, overrideIsActive, } from "./model.js";
3
+ import { merchantMatchesCoBrandBenefit, offerMatches } from "./matching.js";
4
+ import { specFor } from "./configurable.js";
5
+ import { basisLabel, cashOutCPP, centsPerPoint, earnUnitLabel, effectiveProgram, fmt1, valuesPointsAboveCash, withPooling, } from "./valuation.js";
6
+ // ── formatting (Swift twins) ──
7
+ export function formatPercent(v) {
8
+ return v === Math.round(v) ? `${Math.round(v)}%` : `${fmt1(v)}%`;
9
+ }
10
+ export function formatMultiplier(v) {
11
+ return v === Math.round(v) ? `${Math.round(v)}x` : `${fmt1(v)}x`;
12
+ }
13
+ /** shortName: trim issuer/boilerplate so a card name fits a one-line note. */
14
+ function shortName(name) {
15
+ let s = name;
16
+ for (const term of ["American Express", "Bank of America", "Wells Fargo", "Capital One", "Credit Card", " Card", "®", "™"]) {
17
+ s = s.split(term).join(term === "American Express" ? "Amex" : "");
18
+ }
19
+ return s.trim();
20
+ }
21
+ const TRANSFERABLE = new Set(["amexMR", "chaseUR", "capOneMiles", "citiTYP", "wellsFargo"]);
22
+ function optionalityRank(card, v) {
23
+ const program = effectiveProgram(v, card);
24
+ const isTransferable = TRANSFERABLE.has(program);
25
+ const isCash = program === "cash";
26
+ if (v.mode === "smart")
27
+ return isTransferable ? 2 : (isCash ? 0 : 1);
28
+ if (!isCash && cashOutCPP(program) >= 1.0)
29
+ return 3;
30
+ if (isCash)
31
+ return 2;
32
+ return isTransferable ? 1 : 0;
33
+ }
34
+ const PREMIUM_NEEDLES = [
35
+ "sapphire reserve", "sapphire preferred", "venture x", "platinum card",
36
+ "gold card", "amex gold", "strata premier", "citi premier", "prestige",
37
+ "ritz-carlton", "aspire", "altitude reserve", "brilliant",
38
+ ];
39
+ function premiumRank(card) {
40
+ const n = card.displayName.toLowerCase();
41
+ return PREMIUM_NEEDLES.some((x) => n.includes(x)) ? 1 : 0;
42
+ }
43
+ function tieNote(winner, runnerUp, value, v) {
44
+ const rate = formatPercent(value);
45
+ const other = shortName(runnerUp.displayName);
46
+ const win = shortName(winner.displayName);
47
+ const wRank = optionalityRank(winner, v), rRank = optionalityRank(runnerUp, v);
48
+ if (wRank > rRank) {
49
+ const winProgram = effectiveProgram(v, winner);
50
+ if (v.mode === "cash" && winProgram !== "cash" && cashOutCPP(winProgram) >= 1.0) {
51
+ return `${win} ties ${other} at ${rate} — picked ${win}: its points cash out the same, and can be worth more if you ever redeem for travel.`;
52
+ }
53
+ const smart = { mode: "smart", pooledPrograms: v.pooledPrograms };
54
+ if (v.mode === "cash" && centsPerPoint(smart, runnerUp) > 1.0) {
55
+ return `${win} and ${other} tie at ${rate} for cash. ${other}'s points can be worth more if you redeem for travel — switch to Points & miles to compare.`;
56
+ }
57
+ const why = v.mode === "smart" ? "transfer flexibility" : "guaranteed cash back";
58
+ return `${win} ties ${other} at ${rate} — picked ${win} for ${why}.`;
59
+ }
60
+ if (premiumRank(winner) > premiumRank(runnerUp)) {
61
+ return `${win} ties ${other} at ${rate} — picked ${win}: the premium card's purchase protections come free at the same rate.`;
62
+ }
63
+ return `${win} ties ${other} at ${rate} — either works.`;
64
+ }
65
+ const PORTAL_BONUSES = [
66
+ { needles: ["sapphire reserve"], rate: 10, portal: "Chase Travel", premium: true },
67
+ { needles: ["sapphire preferred"], rate: 5, portal: "Chase Travel", premium: true },
68
+ { needles: ["freedom flex", "freedom unlimited"], rate: 5, portal: "Chase Travel", premium: false },
69
+ { needles: ["venture x"], rate: 10, portal: "Capital One Travel", premium: true },
70
+ { needles: ["venture"], rate: 5, portal: "Capital One Travel", premium: false },
71
+ { needles: ["strata premier", "citi premier"], rate: 10, portal: "Citi Travel", premium: true },
72
+ { needles: ["platinum card", "amex platinum", "american express platinum"], rate: 5, portal: "Amex Travel", premium: true },
73
+ ];
74
+ function portalBonus(card) {
75
+ const hay = (card.id + " " + card.displayName).toLowerCase();
76
+ return PORTAL_BONUSES.find((pb) => pb.needles.some((n) => hay.includes(n))) ?? null;
77
+ }
78
+ function portalNote(cards, recommended) {
79
+ let best = null;
80
+ for (const card of cards) {
81
+ const pb = portalBonus(card);
82
+ if (!pb)
83
+ continue;
84
+ let better;
85
+ if (best === null)
86
+ better = true;
87
+ else if (pb.rate !== best.pb.rate)
88
+ better = pb.rate > best.pb.rate;
89
+ else
90
+ better = pb.premium && !best.pb.premium;
91
+ if (better)
92
+ best = { card, pb };
93
+ }
94
+ if (!best)
95
+ return null;
96
+ const everyday = multiplier(recommended, "travel", null);
97
+ if (!(best.pb.rate > everyday))
98
+ return null;
99
+ return `Booking through ${best.pb.portal}? ${shortName(best.card.displayName)} earns up to ${formatMultiplier(best.pb.rate)} there.`;
100
+ }
101
+ function appliedRate(card, category, subKey, merchantQuery, overrides, cardConfigs, now, strict) {
102
+ let rate = multiplier(card, category, subKey);
103
+ let winningOverride = null;
104
+ let winningConfig = null;
105
+ for (const o of overrides) {
106
+ if (o.cardId !== card.id || !overrideIsActive(o, now))
107
+ continue;
108
+ let matches;
109
+ if (o.merchant && o.merchant !== "") {
110
+ if (!merchantQuery || merchantQuery === "")
111
+ continue;
112
+ matches = isPermanentMerchantBenefit(o, now)
113
+ ? merchantMatchesCoBrandBenefit(o.merchant, merchantQuery)
114
+ : offerMatches(o.merchant, merchantQuery, strict);
115
+ }
116
+ else {
117
+ matches = o.category.trim().toLowerCase() === category;
118
+ }
119
+ if (matches && o.bonusRate > rate) {
120
+ rate = o.bonusRate;
121
+ winningOverride = o;
122
+ winningConfig = null;
123
+ }
124
+ }
125
+ const spec = specFor(card);
126
+ if (spec && spec.eligible.includes(category) && cardConfigs[card.id]?.has(category) && spec.bonusRate > rate) {
127
+ rate = spec.bonusRate;
128
+ winningConfig = spec;
129
+ winningOverride = null;
130
+ }
131
+ return { rate, override: winningOverride, configured: winningConfig };
132
+ }
133
+ function bestBaseCard(cards, category, subKey, merchantQuery, overrides, cardConfigs, v, now, strict) {
134
+ const candidates = cards.map((card) => {
135
+ const a = appliedRate(card, category, subKey, merchantQuery, overrides, cardConfigs, now, strict);
136
+ return { card, earn: a.rate, value: a.rate * centsPerPoint(v, card), override: a.override, configured: a.configured };
137
+ });
138
+ if (candidates.length === 0)
139
+ return null;
140
+ const maxValue = Math.max(...candidates.map((c) => c.value));
141
+ const tied = candidates.filter((c) => Math.abs(c.value - maxValue) < 0.0001).sort((a, b) => {
142
+ const ra = optionalityRank(a.card, v), rb = optionalityRank(b.card, v);
143
+ if (ra !== rb)
144
+ return rb - ra;
145
+ const pa = premiumRank(a.card), pb = premiumRank(b.card);
146
+ if (pa !== pb)
147
+ return pb - pa;
148
+ return a.card.displayName.localeCompare(b.card.displayName, "en", { sensitivity: "accent" });
149
+ });
150
+ const w = tied[0];
151
+ if (!w)
152
+ return null;
153
+ const note = tied.length >= 2 ? tieNote(w.card, tied[1].card, w.value, v) : null;
154
+ return { card: w.card, earn: w.earn, valueCents: w.value, override: w.override, configured: w.configured, tieNote: note };
155
+ }
156
+ function rateContext(card, earn, valueCents, override, configured, v, category, now, chosenCategoryCount) {
157
+ const valueStr = formatPercent(valueCents);
158
+ const cat = CATEGORY_DISPLAY[category].toLowerCase();
159
+ const disp = CATEGORY_DISPLAY[category];
160
+ let reason;
161
+ const lines = [];
162
+ const pointsAbove = valuesPointsAboveCash(v, card) && card.rewardCurrency !== null;
163
+ if (configured) {
164
+ const bonusStr = formatPercent(configured.bonusRate);
165
+ reason = `${card.displayName} is your choose-your-category card — you set ${disp} for ${bonusStr}, the most in your wallet here.`;
166
+ if (chosenCategoryCount > configured.slots) {
167
+ reason += ` You hold more than one — use the copy you set for ${disp}.`;
168
+ lines.push(`${bonusStr} on ${disp} — your chosen category (the ${disp} copy)`);
169
+ }
170
+ else {
171
+ lines.push(`${bonusStr} on ${disp} — your chosen category`);
172
+ }
173
+ lines.push(configured.note);
174
+ }
175
+ else if (override) {
176
+ const scope = override.merchant ? `at ${override.merchant}` : cat;
177
+ if (isPermanentMerchantBenefit(override, now)) {
178
+ reason = `${card.displayName} earns ${valueStr} ${scope} — the most in your wallet here.`;
179
+ lines.push(`${valueStr} ${scope} — card benefit`);
180
+ }
181
+ else {
182
+ const what = override.label ?? `${formatPercent(override.bonusRate)} rotating ${scope} bonus`;
183
+ reason = `${card.displayName} has a rotating bonus ${scope} active right now — ${valueStr} this quarter, the most in your wallet.`;
184
+ lines.push(`Rotating bonus: ${what}`);
185
+ }
186
+ if (pointsAbove) {
187
+ lines.push(`${formatMultiplier(override.bonusRate)} ${earnUnitLabel(v, card)} × ${fmt1(centsPerPoint(v, card))}¢ → ${valueStr} per $1`);
188
+ }
189
+ if (override.requiresActivation) {
190
+ const issuer = card.issuer ?? "issuer";
191
+ reason += ` Activate it in your ${issuer} app to earn the bonus.`;
192
+ lines.push(`⚠︎ Activate this quarter in your ${issuer} app`);
193
+ }
194
+ }
195
+ else if (pointsAbove) {
196
+ const earnStr = formatMultiplier(earn);
197
+ const unit = earnUnitLabel(v, card);
198
+ const cppStr = fmt1(centsPerPoint(v, card));
199
+ reason = `${card.displayName} earns ${earnStr} ${unit} on ${cat} — at ${cppStr}¢ each that's ${valueStr} per dollar, the most in your wallet.`;
200
+ lines.push(`Earns ${earnStr} ${unit}`);
201
+ lines.push(`Valued at ${cppStr}¢/pt → ${valueStr} per $1`);
202
+ }
203
+ else {
204
+ reason = `${card.displayName} earns the most for ${cat} — ${valueStr} per dollar.`;
205
+ lines.push(`Base ${cat} earn: ${valueStr}`);
206
+ }
207
+ let caption = configured ? "your bonus category · value per $1" : override ? "rotating bonus · value per $1" : "value per $1";
208
+ let valueMath = null;
209
+ let valueMathShort = null;
210
+ if (pointsAbove) {
211
+ caption = `${formatMultiplier(earn)} ${earnUnitLabel(v, card)} · ${caption}`;
212
+ const effectiveEarn = configured?.bonusRate ?? override?.bonusRate ?? earn;
213
+ const cpp = centsPerPoint(v, card);
214
+ valueMath = `${formatMultiplier(effectiveEarn)} ${earnUnitLabel(v, card)} × ${fmt1(cpp)}¢ = ${fmt1(effectiveEarn * cpp)}¢ per $1`;
215
+ const cppShort = cpp === Math.round(cpp) ? `${Math.round(cpp)}¢` : `${fmt1(cpp)}¢`;
216
+ valueMathShort = `${formatMultiplier(effectiveEarn)} pts × ${cppShort}`;
217
+ }
218
+ return {
219
+ cardId: card.id, cardName: card.displayName, rateDisplay: valueStr, valueCaption: caption, reason,
220
+ breakdownLines: lines, loggableRate: valueCents, valueMath, valueMathShort,
221
+ valuationBasis: basisLabel(v, card), valuationMode: v.mode, tieNote: null, portalNote: null, subcategoryBreakdown: [],
222
+ };
223
+ }
224
+ function subcategoryBreakdown(cards, category, overrides, cardConfigs, v, now) {
225
+ let subs;
226
+ if (category === "grocery")
227
+ subs = [["in_store", "In a store"], ["online", "Online / delivery"]];
228
+ else if (category === "travel")
229
+ subs = [["flight", "Flights"], ["hotel", "Hotels"]];
230
+ else
231
+ return [];
232
+ const rows = [];
233
+ for (const [key, label] of subs) {
234
+ const w = bestBaseCard(cards, category, key, null, overrides, cardConfigs, v, now, false);
235
+ if (!w)
236
+ continue;
237
+ rows.push({ label, cardName: w.card.displayName, rate: formatPercent(w.valueCents) });
238
+ }
239
+ const distinct = new Set(rows.map((r) => `${r.cardName}|${r.rate}`));
240
+ return distinct.size > 1 ? rows : [];
241
+ }
242
+ /** MerchantYieldEngine.recommend — base-rate mode (offers: []) */
243
+ export function recommend(input) {
244
+ const { cards, category, overrides, now } = input;
245
+ if (cards.length === 0)
246
+ return null;
247
+ const v = withPooling(input.valuation, cards);
248
+ const cardConfigs = input.cardConfigs ?? {};
249
+ const strict = input.strictMerchantMatch ?? false;
250
+ const subKey = input.subKey ?? null;
251
+ const merchantName = input.merchantName && input.merchantName.trim() !== "" ? input.merchantName : null;
252
+ const query = merchantName ? merchantName.toLowerCase() : null;
253
+ const w = bestBaseCard(cards, category, subKey, query, overrides, cardConfigs, v, now, strict);
254
+ if (!w)
255
+ return null;
256
+ const ctx = rateContext(w.card, w.earn, w.valueCents, w.override, w.configured, v, category, now, cardConfigs[w.card.id]?.size ?? 0);
257
+ ctx.tieNote = w.tieNote;
258
+ if (category === "travel")
259
+ ctx.portalNote = portalNote(cards, w.card);
260
+ if (input.merchantName == null && input.subKey == null) {
261
+ ctx.subcategoryBreakdown = subcategoryBreakdown(cards, category, overrides, cardConfigs, v, now);
262
+ }
263
+ return ctx;
264
+ }
@@ -0,0 +1,7 @@
1
+ /** MerchantYieldEngine.merchantMatches(offer:query:) */
2
+ export declare function merchantMatches(offerMerchant: string, query: string): boolean;
3
+ /** MerchantYieldEngine.merchantMatchesStrict(offer:place:) */
4
+ export declare function merchantMatchesStrict(offerMerchant: string, place: string): boolean;
5
+ export declare function offerMatches(offer: string, query: string, strict: boolean): boolean;
6
+ /** MerchantYieldEngine.merchantMatchesCoBrandBenefit(benefit:query:) */
7
+ export declare function merchantMatchesCoBrandBenefit(benefit: string, query: string): boolean;
@@ -0,0 +1,93 @@
1
+ // Mirrors MerchantYieldEngine's brand matchers (merchantMatches, merchantMatchesStrict,
2
+ // merchantMatchesCoBrandBenefit) — brand identity, not token overlap.
3
+ const MERCHANT_STOPWORDS = new Set([
4
+ "the", "and", "for", "inc", "llc", "ltd", "co", "shop", "store", "stores",
5
+ "online", "subscription", "subscriptions", "rent", "rental", "rentals",
6
+ "car", "cars", "hotel", "hotels", "resort", "resorts", "international",
7
+ "monthly", "annual", "premium", "collection", "rewards", "select",
8
+ "destinations", "prepaid", "purchases", "amex", "american", "express",
9
+ ]);
10
+ const ARTICLE_STOPWORDS = new Set(["the", "a", "an", "of", "and", "at"]);
11
+ const GENERIC_PLACE = new Set([
12
+ "gas", "station", "fuel", "fueling", "market", "supermarket", "mart",
13
+ "store", "stores", "pharmacy", "drugstore", "supercenter", "super",
14
+ "center", "centre", "outlet", "express", "shopping", "inc", "llc", "farmers",
15
+ ]);
16
+ const COBRAND_GENERIC = new Set(["incorporated", "corporation", "company"]);
17
+ const TLD_RE = /\.(com|net|org|co|io|tv|shop|store|us)\b/g;
18
+ const isAlnum = (ch) => /[\p{L}\p{N}]/u.test(ch);
19
+ const cache = new Map();
20
+ function forms(s) {
21
+ const hit = cache.get(s);
22
+ if (hit)
23
+ return hit;
24
+ const lowered = s.toLowerCase()
25
+ .replace(/&/g, " and ")
26
+ .replace(/(?<=[\p{L}\p{N}])\+(?=[\p{L}\p{N}])/gu, " and ")
27
+ .replace(/(?<=\s)\+(?=\s)/g, " and ");
28
+ const stripped = lowered.replace(TLD_RE, " ");
29
+ const parts = stripped.split(/[^\p{L}\p{N}]+/u).filter((p) => p.length > 0);
30
+ const out = {
31
+ brand: parts.filter((p) => p.length >= 2 && !MERCHANT_STOPWORDS.has(p) && !ARTICLE_STOPWORDS.has(p)),
32
+ significant: new Set(parts.filter((p) => p.length >= 2 && !ARTICLE_STOPWORDS.has(p))),
33
+ compact: parts.join(""),
34
+ };
35
+ if (cache.size > 4096)
36
+ cache.clear();
37
+ cache.set(s, out);
38
+ return out;
39
+ }
40
+ const eq = (a, b) => a.size === b.size && [...a].every((x) => b.has(x));
41
+ const isSubset = (a, b) => [...a].every((x) => b.has(x));
42
+ const isStrictSubset = (a, b) => a.size < b.size && isSubset(a, b);
43
+ const minus = (a, b) => new Set([...a].filter((x) => !b.has(x)));
44
+ /** MerchantYieldEngine.merchantMatches(offer:query:) */
45
+ export function merchantMatches(offerMerchant, query) {
46
+ const o = forms(offerMerchant).brand, q = forms(query).brand;
47
+ if (o.length > 0 && q.length > 0) {
48
+ const os = new Set(o), qs = new Set(q);
49
+ if (eq(os, qs))
50
+ return true;
51
+ if (isStrictSubset(os, qs) && (isSubset(minus(qs, os), GENERIC_PLACE) || arrEq(q.slice(0, o.length), o)))
52
+ return true;
53
+ if (isStrictSubset(qs, os) && isSubset(minus(os, qs), GENERIC_PLACE))
54
+ return true;
55
+ }
56
+ const cm = forms(offerMerchant).compact, cq = forms(query).compact;
57
+ if (cm.length < 4 || cq.length < 4)
58
+ return false;
59
+ if (cm === cq)
60
+ return true;
61
+ const [short, long] = cm.length <= cq.length ? [cm, cq] : [cq, cm];
62
+ if (!long.startsWith(short))
63
+ return false;
64
+ const rest = long.slice(short.length);
65
+ return GENERIC_PLACE.has(rest) || MERCHANT_STOPWORDS.has(rest);
66
+ }
67
+ const arrEq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
68
+ /** MerchantYieldEngine.merchantMatchesStrict(offer:place:) */
69
+ export function merchantMatchesStrict(offerMerchant, place) {
70
+ const o = forms(offerMerchant).significant, p = forms(place).significant;
71
+ if (o.size === 0 || p.size === 0)
72
+ return false;
73
+ if (eq(o, p))
74
+ return true;
75
+ if (isSubset(o, p) && isSubset(minus(p, o), GENERIC_PLACE))
76
+ return true;
77
+ return forms(offerMerchant).compact === forms(place).compact;
78
+ }
79
+ export function offerMatches(offer, query, strict) {
80
+ return strict ? merchantMatchesStrict(offer, query) : merchantMatches(offer, query);
81
+ }
82
+ /** MerchantYieldEngine.merchantMatchesCoBrandBenefit(benefit:query:) */
83
+ export function merchantMatchesCoBrandBenefit(benefit, query) {
84
+ const distinctive = (s) => new Set(s.toLowerCase().replace(/\.(com|net|org|co|io)\b/g, " ")
85
+ .split(/[^\p{L}\p{N}]+/u)
86
+ .filter((t) => t.length >= 4 && !COBRAND_GENERIC.has(t)));
87
+ const b = distinctive(benefit), q = distinctive(query);
88
+ if (b.size === 0 || q.size === 0)
89
+ return false;
90
+ if (![...b].some((x) => q.has(x)))
91
+ return false;
92
+ return isSubset(q, b);
93
+ }
@@ -0,0 +1,55 @@
1
+ export type SpendCategory = "dining" | "grocery" | "travel" | "gas" | "online" | "drugstore" | "transit" | "streaming" | "general";
2
+ export declare const ALL_CATEGORIES: SpendCategory[];
3
+ export declare const CATEGORY_DISPLAY: Record<SpendCategory, string>;
4
+ export type RewardCurrency = "points" | "miles" | "cashback";
5
+ export declare const CURRENCY_DISPLAY: Record<RewardCurrency, string>;
6
+ export type MultiplierField = number | {
7
+ default: number;
8
+ values: Record<string, number>;
9
+ };
10
+ export interface CreditCard {
11
+ id: string;
12
+ displayName: string;
13
+ issuer: string | null;
14
+ rewardCurrency: RewardCurrency | null;
15
+ multipliers: Record<string, MultiplierField>;
16
+ annualFee?: number | null;
17
+ verifyStatus?: string | null;
18
+ }
19
+ /** Supabase `cards` row → CreditCard (SupabaseCard.toCreditCard). */
20
+ export declare function cardFromRow(row: any): CreditCard;
21
+ /** CreditCard.multiplier(for:subKey:) — direct key → "other" for general → "general" → 1.0 */
22
+ export declare function multiplier(card: CreditCard, category: SpendCategory, subKey?: string | null): number;
23
+ /** CreditCard.hasAnyReward */
24
+ export declare function hasAnyReward(card: CreditCard): boolean;
25
+ export interface Override {
26
+ id: string;
27
+ cardId: string;
28
+ category: string;
29
+ bonusRate: number;
30
+ startDate: string;
31
+ endDate: string;
32
+ requiresActivation: boolean;
33
+ label: string | null;
34
+ merchant: string | null;
35
+ }
36
+ export declare function overrideFromRow(r: any): Override;
37
+ /** UTC-day canon (SupabaseDateFormat.utcCalendar): compare yyyy-MM-dd strings as UTC days. */
38
+ export declare function utcDay(now: Date): string;
39
+ export declare function overrideIsActive(o: Override, now: Date): boolean;
40
+ /** SupabaseOverride.isPermanentMerchantBenefit: merchant-scoped, no activation, ends > 2 years out. */
41
+ export declare function isPermanentMerchantBenefit(o: Override, now: Date): boolean;
42
+ export interface Merchant {
43
+ id: string;
44
+ displayName: string;
45
+ category: SpendCategory;
46
+ subKey: string | null;
47
+ isOnline: boolean;
48
+ aliases: string[];
49
+ merchantType: string | null;
50
+ }
51
+ export declare function merchantFromRow(r: any): Merchant;
52
+ /** Merchant.exclusionCaveat */
53
+ export declare function exclusionCaveat(m: Merchant): string | null;
54
+ /** Merchant.isCuratedBrand */
55
+ export declare function isCuratedBrand(m: Merchant): boolean;