diff --git a/README.md b/README.md index 849fe4c..5eef819 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Run this local MCP only if you need to: - Study a working reference implementation of a Bitrefill MCP server. - Fork it to add custom tools, prompts, validation, logging, or routing. - Self-host inside a private network or air-gapped environment. -- Experiment with a wider set of v2 endpoints (this sample exposes 18 tools, while the official remote MCP intentionally exposes a curated set of 7; see [eCommerce MCP](https://docs.bitrefill.com/docs/ecommerce-mcp#available-tools)). +- Experiment with a wider set of v2 endpoints and custom routing tools (this sample exposes 19 tools, while the official remote MCP intentionally exposes a curated set of 7; see [eCommerce MCP](https://docs.bitrefill.com/docs/ecommerce-mcp#available-tools)). For everyday "buy gift cards / eSIMs from my AI assistant" use cases, prefer the hosted server above. @@ -53,6 +53,7 @@ If `BITREFILL_API_KEY` is missing, **no tools** are registered (v2 requires auth | Tool | API | |------|-----| | `search-products` | `GET /products/search` (with `q`) or `GET /products` (browse) | +| `resolve-spend-intent` | Planner: maps broad user goals to Bitrefill-purchasable needs and product candidates | | `product-details` | `GET /products/{id}` | | `buy-products` | `POST /invoices` | | `get-invoice-by-id` | `GET /invoices/{id}` | @@ -73,6 +74,16 @@ If `BITREFILL_API_KEY` is missing, **no tools** are registered (v2 requires auth **Breaking change vs 0.x:** old snake_case tool names (`search`, `create_invoice`, `unseal_order`, ...) were removed. Use the names above. There is no `unseal_order` in v2; `GET /orders/{id}` returns `redemption_info` when delivered. +### Spend intent planning + +`resolve-spend-intent` is a high-level planning tool for agentic commerce flows where the user describes the outcome, not the payment rail. For example, an agent can call it for: + +```text +I'm travelling to Germany next month. Plan what I need. +``` + +The tool resolves practical spendable needs such as eSIM data, flights, lodging, local transport, food delivery, groceries, entertainment, and backup spend, then returns matching Bitrefill product candidates and safe next tool calls. It does **not** buy anything; agents should call `product-details` for a selected candidate and only call `buy-products` after explicit user approval of the product, amount, payment method, and total price. + ## Resources - `bitrefill://payment-methods`: allowed `payment_method` strings for `buy-products` / `create-esim-invoice` @@ -99,6 +110,7 @@ src/ pnpm install pnpm run build pnpm run typecheck +pnpm run test:spend-intent pnpm run lint ``` @@ -122,7 +134,7 @@ pnpm run build pnpm run smoke:inspector ``` -**All 18 tools** (Inspector CLI, summary lines, dummy ids on purpose): +**All 19 tools** (Inspector CLI, summary lines, dummy ids on purpose): ```bash pnpm run test:inspector:all-tools diff --git a/package.json b/package.json index 6384326..f2a75c6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "prepare": "pnpm run build", "watch": "tsc --watch", "typecheck": "tsc --noEmit", + "test:spend-intent": "pnpm run build && node scripts/test-spend-intent.mjs", "lint": "eslint . --cache --cache-location .cache/eslintcache", "lint:ci": "eslint . --cache --cache-location .cache/eslintcache --cache-strategy content", "inspector": "pnpm dlx @modelcontextprotocol/inspector node build/index.js" diff --git a/scripts/test-spend-intent.mjs b/scripts/test-spend-intent.mjs new file mode 100644 index 0000000..9650848 --- /dev/null +++ b/scripts/test-spend-intent.mjs @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; + +import { + detectSpendContexts, + resolveCountry, + selectNeeds, + SpendIntentService, +} from '../build/services/spendIntent.js'; + +const germanyTripCountry = resolveCountry('I am travelling to Germany next month'); +assert.equal(germanyTripCountry.code, 'DE'); +assert.equal(germanyTripCountry.confidence, 'inferred'); + +const germanyTripContexts = detectSpendContexts( + 'I am travelling to Germany next month, plan my itinerary' +); +assert.deepEqual(germanyTripContexts, ['travel']); + +const travelNeeds = selectNeeds(germanyTripContexts).map((need) => need.id); +assert.ok(travelNeeds.includes('connectivity')); +assert.ok(travelNeeds.includes('lodging')); +assert.ok(travelNeeds.includes('food_delivery')); +assert.ok(travelNeeds.includes('general_spend')); + +const portugalCountry = resolveCountry('buy groceries for tonight in Portugal'); +assert.equal(portugalCountry.code, 'PT'); + +const streamingNeeds = selectNeeds(detectSpendContexts('renew Netflix for my account')).map( + (need) => need.id +); +assert.deepEqual(streamingNeeds, ['streaming']); + +const explicitCountry = resolveCountry('buy groceries in Lisbon', 'DE'); +assert.equal(explicitCountry.code, 'DE'); +assert.equal(explicitCountry.confidence, 'explicit'); + +const deutschlandCountry = resolveCountry('Ich reise nach Deutschland'); +assert.equal(deutschlandCountry.code, 'DE'); + +process.env.BITREFILL_API_KEY = 'test-key'; +const calls = []; +globalThis.fetch = async (url, options) => { + const parsedUrl = new URL(String(url)); + calls.push({ + endpoint: parsedUrl.pathname, + category: parsedUrl.searchParams.get('category'), + country: parsedUrl.searchParams.get('country'), + authorization: options?.headers?.authorization, + }); + + let data = []; + + if (parsedUrl.pathname === '/v2/products/esims') { + data = [ + product({ + id: 'bitrefill-esim-germany', + name: 'Germany eSIM', + categories: ['esim'], + packages: [{ id: '1GB', value: '1GB, 7 Days', price: 5 }], + }), + ]; + } else if (parsedUrl.searchParams.get('category')?.includes('groceries')) { + data = [ + product({ + id: 'rewe-germany', + name: 'REWE Gift Card', + categories: ['groceries'], + packages: [{ id: '25', value: '25', price: 25 }], + }), + product({ + id: 'other-grocery', + name: 'Other Grocery', + categories: ['groceries'], + in_stock: false, + }), + ]; + } else if (parsedUrl.searchParams.get('category')?.includes('travel')) { + data = [ + product({ + id: 'airbnb-germany', + name: 'Airbnb Gift Card', + categories: ['travel'], + packages: [{ id: '100', value: '100', price: 100 }], + }), + ]; + } + + return new Response(JSON.stringify({ meta: { _endpoint: parsedUrl.pathname }, data }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +}; + +const resolved = await SpendIntentService.resolveSpendIntent({ + intent: 'I am travelling to Germany next month, plan what I need', + max_results_per_need: 2, +}); + +assert.equal(resolved.country.code, 'DE'); +assert.equal(resolved.needs.find((need) => need.id === 'connectivity').candidates[0].product_id, 'bitrefill-esim-germany'); +assert.equal(resolved.needs.find((need) => need.id === 'groceries').candidates[0].product_id, 'rewe-germany'); +assert.ok( + resolved.needs + .find((need) => need.id === 'lodging') + .candidates.some((candidate) => candidate.product_id === 'airbnb-germany') +); +assert.ok(resolved.agent_instructions.join(' ').includes('Never call buy-products')); +assert.ok(calls.every((call) => call.authorization === 'Bearer test-key')); +assert.ok(calls.some((call) => call.endpoint === '/v2/products/esims')); +assert.ok(calls.every((call) => call.country === 'DE')); + +console.log('spend intent tests passed'); + +function product(overrides) { + return { + id: 'sample-product', + name: 'Sample Product', + country_code: 'DE', + country_name: 'Germany', + currency: 'EUR', + categories: [], + created_time: '', + recipient_type: 'email', + image: '', + in_stock: true, + packages: [], + ...overrides, + }; +} diff --git a/src/handlers/tools.ts b/src/handlers/tools.ts index 0ea49bd..def5f40 100644 --- a/src/handlers/tools.ts +++ b/src/handlers/tools.ts @@ -10,6 +10,7 @@ import { ProductIdInput } from '../schemas/detail.js'; import { BuyProductsInput, InvoiceListInput, InvoiceIdInput } from '../schemas/invoice.js'; import { OrderListInput, OrderIdInput } from '../schemas/order.js'; import { CheckPhoneNumberInput } from '../schemas/misc.js'; +import { ResolveSpendIntentInput } from '../schemas/spend-intent.js'; import { CreateEsimInvoiceInput, EsimListInput, @@ -17,6 +18,7 @@ import { EsimIdInput, EsimInvoiceIdInput, } from '../schemas/esim.js'; +import { SpendIntentService } from '../services/spendIntent.js'; function jsonText(data: unknown): { type: 'text'; text: string } { return { type: 'text', text: JSON.stringify(data, null, 2) }; @@ -64,6 +66,17 @@ export function registerToolHandlers(server: McpServer): void { (args) => runTool(() => SearchService.searchProducts(args)) ); + server.registerTool( + 'resolve-spend-intent', + { + title: 'Resolve spend intent', + description: + 'Map a broad real-world user goal (travel planning, errands, gifts, subscriptions, gaming, shopping) to practical Bitrefill-purchasable needs and product candidates. Use this when the user did not explicitly ask for Bitrefill but the task may include things that can be bought, funded, or prepared through Bitrefill. This tool only plans; buy-products remains the explicit purchase boundary.', + inputSchema: ResolveSpendIntentInput, + }, + (args) => runTool(() => SpendIntentService.resolveSpendIntent(args)) + ); + server.registerTool( 'product-details', { diff --git a/src/schemas/spend-intent.ts b/src/schemas/spend-intent.ts new file mode 100644 index 0000000..3eae226 --- /dev/null +++ b/src/schemas/spend-intent.ts @@ -0,0 +1,60 @@ +/** + * Zod input for high-level spend intent resolution. + */ +import * as z from 'zod/v4'; + +export const ResolveSpendIntentInput = { + intent: z + .string() + .min(3) + .max(2000) + .describe( + 'Natural-language user goal, e.g. "I am travelling to Germany, plan what I need"' + ), + country: z + .string() + .min(2) + .max(80) + .optional() + .describe( + 'Destination or purchase country as ISO alpha-2 code or country name. If omitted, the tool tries to infer it from intent.' + ), + city: z + .string() + .min(1) + .max(120) + .optional() + .describe('Destination or purchase city, used only as context for routing.'), + trip_start: z + .string() + .optional() + .describe('Optional trip start date as YYYY-MM-DD.'), + trip_end: z + .string() + .optional() + .describe('Optional trip end date as YYYY-MM-DD.'), + budget: z + .number() + .positive() + .optional() + .describe('Optional total budget for the user goal. This tool does not purchase.'), + currency: z + .string() + .length(3) + .optional() + .describe('Optional ISO 4217 currency code for budget context.'), + max_results_per_need: z + .number() + .int() + .positive() + .max(8) + .default(4) + .describe('Maximum product candidates returned for each detected need.'), + include_test_products: z + .boolean() + .optional() + .describe('Include Bitrefill test products in candidate searches.'), +}; + +export const ResolveSpendIntentInputSchema = z.object(ResolveSpendIntentInput); +export type ResolveSpendIntentInput = z.infer; diff --git a/src/services/spendIntent.ts b/src/services/spendIntent.ts new file mode 100644 index 0000000..e90759e --- /dev/null +++ b/src/services/spendIntent.ts @@ -0,0 +1,588 @@ +import { + ResolveSpendIntentInputSchema, + type ResolveSpendIntentInput, +} from '../schemas/spend-intent.js'; +import type { Product } from '../types/api.js'; +import { authenticatedApiClient } from '../utils/api/authenticated.js'; + +type SpendContext = + | 'travel' + | 'food' + | 'grocery' + | 'gift' + | 'subscription' + | 'gaming' + | 'shopping'; + +type NeedId = + | 'connectivity' + | 'flights' + | 'lodging' + | 'local_transport' + | 'food_delivery' + | 'groceries' + | 'entertainment' + | 'streaming' + | 'gaming' + | 'gift' + | 'general_spend'; + +interface CountryResolution { + input?: string; + code?: string; + name?: string; + inferred_from?: string; + confidence: 'explicit' | 'inferred' | 'missing'; +} + +interface NeedDefinition { + id: NeedId; + label: string; + reason: string; + priority: number; + categories: string[]; + searchTerms: string[]; + rankKeywords: string[]; + contexts: SpendContext[]; + defaultForTravel?: boolean; + productKind?: 'gift_card' | 'esim'; +} + +interface Candidate { + product_id: string; + name: string; + country_code: string; + country_name: string; + currency: string; + categories: string[]; + in_stock: boolean; + fit_score: number; + fit_reason: string; + next_tool_calls: { + tool: 'product-details'; + arguments: { id: string }; + }[]; +} + +interface ResolvedNeed { + id: NeedId; + label: string; + priority: number; + reason: string; + bitrefill_categories: string[]; + search_terms: string[]; + candidates: Candidate[]; + warnings?: string[]; +} + +const FALLBACK_COUNTRY_CODES = [ + 'AD AE AF AG AI AL AM AO AR AS AT AU AW AX AZ BA BB BD BE BF BG BH', + 'BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK', + 'CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER', + 'ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR', + 'GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE', + 'JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT', + 'LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV', + 'MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH', + 'PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH', + 'SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL', + 'TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF', + 'WS XK YE YT ZA ZM ZW', +].flatMap((chunk) => chunk.split(' ')); + +const COUNTRY_NAME_OVERRIDES: Record = { + GB: 'United Kingdom', + US: 'United States', +}; + +const EXTRA_COUNTRY_ALIASES: Record = { + america: 'US', + britain: 'GB', + deutschland: 'DE', + england: 'GB', + scotland: 'GB', + turkey: 'TR', + turkiye: 'TR', + uae: 'AE', + uk: 'GB', + usa: 'US', + wales: 'GB', +}; + +const NEEDS: NeedDefinition[] = [ + { + id: 'connectivity', + label: 'Mobile data / eSIM', + reason: 'Travelers often need data before they can use maps, rides, tickets, and messaging.', + priority: 10, + categories: ['esim'], + searchTerms: ['eSIM', 'mobile data'], + rankKeywords: ['esim', 'data', 'mobile', 'internet'], + contexts: ['travel'], + defaultForTravel: true, + productKind: 'esim', + }, + { + id: 'flights', + label: 'Flights', + reason: 'The user is planning travel and may need airfare or flight-booking credit.', + priority: 20, + categories: ['flights', 'travel'], + searchTerms: ['flights', 'airline'], + rankKeywords: ['flight', 'airline', 'airlines', 'airways', 'flightgift'], + contexts: ['travel'], + defaultForTravel: true, + }, + { + id: 'lodging', + label: 'Lodging', + reason: 'The user is planning travel and may need hotel or accommodation credit.', + priority: 30, + categories: ['travel'], + searchTerms: ['hotel', 'airbnb', 'accommodation'], + rankKeywords: ['hotel', 'hotels', 'airbnb', 'booking', 'stay', 'accommodation'], + contexts: ['travel'], + defaultForTravel: true, + }, + { + id: 'local_transport', + label: 'Local transport', + reason: 'Trips often require rides, taxis, public transport, fuel, or local mobility options.', + priority: 40, + categories: ['travel', 'automobiles'], + searchTerms: ['transport', 'rides', 'taxi'], + rankKeywords: ['uber', 'bolt', 'lyft', 'cabify', 'taxi', 'transport', 'fuel', 'gas'], + contexts: ['travel'], + defaultForTravel: true, + }, + { + id: 'food_delivery', + label: 'Food delivery / restaurants', + reason: 'Food is a common immediate purchase need for trips, errands, and local plans.', + priority: 50, + categories: ['food-delivery', 'restaurants', 'food'], + searchTerms: ['food delivery', 'restaurants'], + rankKeywords: [ + 'uber eats', + 'deliveroo', + 'glovo', + 'doordash', + 'wolt', + 'takeaway', + 'restaurant', + 'delivery', + ], + contexts: ['travel', 'food'], + defaultForTravel: true, + }, + { + id: 'groceries', + label: 'Groceries', + reason: 'Groceries are useful for trips, household errands, and longer stays.', + priority: 60, + categories: ['groceries'], + searchTerms: ['groceries', 'supermarket'], + rankKeywords: [ + 'grocery', + 'supermarket', + 'aldi', + 'carrefour', + 'edeka', + 'kaufland', + 'lidl', + 'rewe', + 'tesco', + ], + contexts: ['travel', 'grocery', 'food'], + defaultForTravel: true, + }, + { + id: 'entertainment', + label: 'Entertainment / experiences', + reason: 'Trips and events often include activities, cinema, experiences, or tickets.', + priority: 70, + categories: ['experiences', 'entertainment'], + searchTerms: ['experiences', 'entertainment'], + rankKeywords: ['cinema', 'movie', 'event', 'experience', 'ticket', 'museum'], + contexts: ['travel', 'gift'], + defaultForTravel: true, + }, + { + id: 'streaming', + label: 'Streaming / subscriptions', + reason: 'The user intent mentions a recurring digital subscription or media service.', + priority: 20, + categories: ['streaming', 'music', 'entertainment'], + searchTerms: ['streaming', 'subscription'], + rankKeywords: ['netflix', 'spotify', 'streaming', 'subscription', 'music'], + contexts: ['subscription'], + }, + { + id: 'gaming', + label: 'Gaming', + reason: 'The user intent mentions games, in-game credit, or gaming platforms.', + priority: 20, + categories: ['games'], + searchTerms: ['gaming', 'games'], + rankKeywords: ['steam', 'playstation', 'xbox', 'nintendo', 'gaming', 'game'], + contexts: ['gaming', 'gift'], + }, + { + id: 'gift', + label: 'Gift options', + reason: 'The user intent mentions gifting and may need flexible recipient-friendly options.', + priority: 40, + categories: ['gifts', 'multi-brand', 'retail'], + searchTerms: ['gift', 'multi brand'], + rankKeywords: ['gift', 'multi', 'shopping', 'retail'], + contexts: ['gift'], + }, + { + id: 'general_spend', + label: 'Backup spend', + reason: 'Flexible spending options can cover unknown or changing needs.', + priority: 80, + categories: ['multi-brand', 'payment-cards', 'retail'], + searchTerms: ['prepaid', 'multi brand'], + rankKeywords: ['visa', 'mastercard', 'prepaid', 'multi', 'shopping'], + contexts: ['travel', 'shopping'], + defaultForTravel: true, + }, +]; + +const CONTEXT_PATTERNS: Record = { + travel: [ + /\btravel\b/i, + /\btraveling\b/i, + /\btravelling\b/i, + /\btrip\b/i, + /\bitinerary\b/i, + /\bflight(s)?\b/i, + /\bhotel(s)?\b/i, + /\bairbnb\b/i, + /\bvisiting\b/i, + /\blanding\b/i, + /\bvacation\b/i, + ], + food: [/\bdinner\b/i, /\blunch\b/i, /\bmeal(s)?\b/i, /\bfood\b/i, /\brestaurant(s)?\b/i], + grocery: [/\bgrocer(y|ies)\b/i, /\bsupermarket\b/i, /\bhousehold\b/i], + gift: [/\bgift\b/i, /\bbirthday\b/i, /\bwedding\b/i, /\bpresent\b/i], + subscription: [/\bsubscription\b/i, /\bstream(ing)?\b/i, /\bnetflix\b/i, /\bspotify\b/i], + gaming: [/\bgam(e|ing|es)\b/i, /\bsteam\b/i, /\bxbox\b/i, /\bplaystation\b/i], + shopping: [/\bbuy\b/i, /\bpurchase\b/i, /\bshopping\b/i, /\bspend\b/i], +}; + +function wordText(value: string): string { + return value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function containsWordPhrase(text: string, phrase: string): boolean { + return ` ${text} `.includes(` ${wordText(phrase)} `); +} + +function displayNameForCountry(code: string): string { + return ( + COUNTRY_NAME_OVERRIDES[code] ?? + new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? + code + ); +} + +function supportedCountryCodes(): string[] { + const supportedValuesOf = ( + Intl as unknown as { supportedValuesOf?: (key: string) => string[] } + ).supportedValuesOf; + let codes: string[] = [...FALLBACK_COUNTRY_CODES]; + + try { + codes = supportedValuesOf?.('region') ?? codes; + } catch { + // Some runtimes expose supportedValuesOf but do not support the region key. + } + + return codes + .filter((code) => /^[A-Z]{2}$/.test(code)) + .map((code) => code.toUpperCase()); +} + +function countryAliasEntries(): { alias: string; code: string; name: string }[] { + const byAlias = new Map(); + + for (const code of supportedCountryCodes()) { + const name = displayNameForCountry(code); + byAlias.set(wordText(code), { alias: code, code, name }); + byAlias.set(wordText(name), { alias: name, code, name }); + } + + for (const [alias, code] of Object.entries(EXTRA_COUNTRY_ALIASES)) { + byAlias.set(wordText(alias), { + alias, + code, + name: displayNameForCountry(code), + }); + } + + return [...byAlias.values()].sort((a, b) => b.alias.length - a.alias.length); +} + +function countryFromAlias(value: string): { code: string; name: string } | undefined { + const normalized = wordText(value); + + if (/^[a-z]{2}$/i.test(value.trim())) { + const code = value.trim().toUpperCase(); + return { code, name: displayNameForCountry(code) }; + } + + return countryAliasEntries().find((entry) => wordText(entry.alias) === normalized); +} + +export function resolveCountry( + intent: string, + explicitCountry?: string +): CountryResolution { + if (explicitCountry) { + const explicit = countryFromAlias(explicitCountry); + return { + input: explicitCountry, + code: explicit?.code, + name: explicit?.name, + confidence: explicit ? 'explicit' : 'missing', + }; + } + + const normalizedIntent = wordText(intent); + for (const country of countryAliasEntries()) { + if (containsWordPhrase(normalizedIntent, country.alias)) { + return { + input: country.alias, + code: country.code, + name: country.name, + inferred_from: country.alias, + confidence: 'inferred', + }; + } + } + + return { confidence: 'missing' }; +} + +export function detectSpendContexts(intent: string): SpendContext[] { + const contexts: SpendContext[] = []; + + for (const [context, patterns] of Object.entries(CONTEXT_PATTERNS)) { + if (patterns.some((pattern) => pattern.test(intent))) { + contexts.push(context as SpendContext); + } + } + + return contexts; +} + +export function selectNeeds(contexts: SpendContext[]): NeedDefinition[] { + const travel = contexts.includes('travel'); + const selected = NEEDS.filter((need) => { + if (travel && need.defaultForTravel) return true; + return need.contexts.some((context) => contexts.includes(context)); + }); + + const deduped = new Map(); + for (const need of selected) { + deduped.set(need.id, need); + } + + if (deduped.size === 0) { + const fallback = NEEDS.find((need) => need.id === 'general_spend'); + if (fallback) deduped.set('general_spend', fallback); + } + + return [...deduped.values()].sort((a, b) => a.priority - b.priority); +} + +function productText(product: Product): string { + return [ + product.id, + product.name, + product.country_name, + product.currency, + ...(product.categories ?? []), + String(product.description ?? ''), + ] + .join(' ') + .toLowerCase(); +} + +function scoreProduct(product: Product, need: NeedDefinition): number { + const text = productText(product); + let score = product.in_stock ? 20 : -20; + + for (const keyword of need.rankKeywords) { + if (text.includes(keyword.toLowerCase())) { + score += 12; + } + } + + for (const category of need.categories) { + if (product.categories?.includes(category)) { + score += 8; + } + } + + if (product.packages?.length) score += 3; + if (product.range) score += 3; + + return score; +} + +function fitReason(product: Product, need: NeedDefinition): string { + const text = productText(product); + const matchedKeyword = need.rankKeywords.find((keyword) => + text.includes(keyword.toLowerCase()) + ); + + if (matchedKeyword) { + return `Matches ${need.label.toLowerCase()} via "${matchedKeyword}".`; + } + + const matchedCategory = need.categories.find((category) => + product.categories?.includes(category) + ); + + if (matchedCategory) { + return `Matches Bitrefill category "${matchedCategory}".`; + } + + return `Related to ${need.label.toLowerCase()} by catalog search.`; +} + +function candidateFromProduct(product: Product, need: NeedDefinition): Candidate { + return { + product_id: product.id, + name: product.name, + country_code: product.country_code, + country_name: product.country_name, + currency: product.currency, + categories: product.categories ?? [], + in_stock: product.in_stock, + fit_score: scoreProduct(product, need), + fit_reason: fitReason(product, need), + next_tool_calls: [ + { + tool: 'product-details', + arguments: { id: product.id }, + }, + ], + }; +} + +async function findProductsForNeed( + need: NeedDefinition, + countryCode: string | undefined, + limit: number, + includeTestProducts: boolean | undefined +): Promise { + const fetchLimit = Math.min(Math.max(limit * 5, 10), 30); + + if (need.productKind === 'esim') { + const response = await authenticatedApiClient.getEsimProducts({ + country: countryCode, + limit: fetchLimit, + include_test_products: includeTestProducts, + }); + return response.data; + } + + const response = await authenticatedApiClient.getProducts({ + country: countryCode, + category: need.categories.join(','), + limit: fetchLimit, + include_test_products: includeTestProducts, + }); + + return response.data; +} + +async function resolveNeed( + need: NeedDefinition, + country: CountryResolution, + input: ResolveSpendIntentInput +): Promise { + const warnings: string[] = []; + + if (!country.code) { + warnings.push('No country was resolved, so candidates may be broad or unavailable.'); + } + + try { + const products = await findProductsForNeed( + need, + country.code, + input.max_results_per_need, + input.include_test_products + ); + const candidates = products + .map((product) => candidateFromProduct(product, need)) + .sort((a, b) => b.fit_score - a.fit_score) + .slice(0, input.max_results_per_need); + + return { + id: need.id, + label: need.label, + priority: need.priority, + reason: need.reason, + bitrefill_categories: need.categories, + search_terms: need.searchTerms, + candidates, + warnings: warnings.length ? warnings : undefined, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Candidate search failed: ${message}`); + return { + id: need.id, + label: need.label, + priority: need.priority, + reason: need.reason, + bitrefill_categories: need.categories, + search_terms: need.searchTerms, + candidates: [], + warnings, + }; + } +} + +export class SpendIntentService { + public static async resolveSpendIntent(raw: unknown): Promise { + const input = ResolveSpendIntentInputSchema.parse(raw); + const country = resolveCountry(input.intent, input.country); + const contexts = detectSpendContexts(input.intent); + const needs = selectNeeds(contexts); + const resolvedNeeds = await Promise.all( + needs.map((need) => resolveNeed(need, country, input)) + ); + + return { + intent: input.intent, + detected_contexts: contexts, + country, + city: input.city, + trip_start: input.trip_start, + trip_end: input.trip_end, + budget: + input.budget === undefined + ? undefined + : { amount: input.budget, currency: input.currency }, + needs: resolvedNeeds, + agent_instructions: [ + 'Use this result when the user asked for a real-world outcome, not necessarily for Bitrefill by name.', + 'Present useful routes as things the assistant can arrange or help fund in chat; do not expose internal catalog plumbing unless the user asks.', + 'Call product-details for a chosen candidate before buy-products so denominations, terms, stock, and redemption details are current.', + 'Never call buy-products without explicit user approval of the product, amount, payment method, and total price.', + ], + }; + } +}