/** * Pure logic backing the payments widgets. Mirrors the web builder's tools-ui * counterparts (useSelectPaymentProvider.ts, useConfigurePspCredentials.ts * and their utils/) with the English strings from the builder's i18n catalog. */ import type { SuperagentToolCall } from '../types'; import { isActionableTurn, type ToolWidgetStatus } from './toolWidgetUtils'; // ── Payments (select_payment_provider / configure_psp_credentials) ────────── export type ProviderEntry = { key: string; display_name: string; methods: string[]; }; /** * Provider radio entries coerced to well-formed {key, display_name, methods[]} * (web useSelectPaymentProvider parity — args are model-controlled JSON even * though the prepare hook overwrites them with the trusted list). */ export function normalizeProviders(args: Record | null): ProviderEntry[] { const raw = args?.providers; if (!Array.isArray(raw)) return []; return raw .filter((provider): provider is Record => Boolean(provider) && typeof provider === 'object' && typeof (provider as ProviderEntry).key === 'string' && (provider as ProviderEntry).key.trim() !== '' && typeof (provider as ProviderEntry).display_name === 'string' && (provider as ProviderEntry).display_name.trim() !== '') .map((provider) => ({ key: provider.key as string, display_name: provider.display_name as string, methods: Array.isArray(provider.methods) ? provider.methods.filter((method): method is string => typeof method === 'string') : [], })); } const METHOD_LABEL: Record = { applePay: 'Apple Pay', bit: 'Bit', creditCard: 'Credit card', googlePay: 'Google Pay', max: 'Max', }; export function formatMethods(methods: string[]): string { return methods.map((method) => METHOD_LABEL[method] ?? method).join(' · '); } export type PspOption = { label: string; value: string }; export type PspField = { name: string; label: string; type?: string; options: PspOption[]; }; /** Wix marks secret fields with type PASSWORD — render them masked. */ export function isSecretPspField(field: PspField): boolean { return field.type === 'PASSWORD'; } function normalizePspOptions(options: unknown): PspOption[] { if (!Array.isArray(options)) return []; const out: PspOption[] = []; for (const option of options) { if (typeof option === 'string') { out.push({ label: option, value: option }); } else if (option && typeof option === 'object') { const record = option as Record; const value = record.value ?? record.id ?? record.key; const label = record.label ?? record.title ?? record.name ?? value; if (typeof value === 'string') out.push({ label: typeof label === 'string' ? label : value, value }); } } return out; } /** args.schema as a plain object (model-controlled — may be anything). */ export function getPspSchema(args: Record | null): Record | null { const schema = args?.schema; return schema && typeof schema === 'object' && !Array.isArray(schema) ? (schema as Record) : null; } /** No hardcoded provider: display_name → schema title → raw key → generic. */ export function getPspProcessorName(args: Record | null): string { return asTrimmedString(args?.display_name) ?? asTrimmedString(getPspSchema(args)?.title) ?? asTrimmedString(args?.processor_type) ?? 'your payment processor'; } /** * schema.fields coerced to entries with a string `name` (the key the backend * requires), label falling back to the name (web useConfigurePspCredentials * parity — dropping name-only fields would make Connect fail server-side). */ export function normalizePspFields(args: Record | null): PspField[] { const fields = getPspSchema(args)?.fields; if (!Array.isArray(fields)) return []; return fields .filter((field): field is Record => Boolean(field) && typeof field === 'object' && typeof (field as Record).name === 'string' && ((field as Record).name as string).trim() !== '') .map((field) => ({ name: field.name as string, label: typeof field.label === 'string' && field.label.trim() ? field.label : (field.name as string), type: typeof field.type === 'string' ? field.type : undefined, options: normalizePspOptions(field.options), })); } /** args are model-controlled — only display values that are real strings. */ export function asTrimmedString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } /** * Prompt-injection guard: only http(s) URLs may be opened from tool args. * Parsed with URL (web parity, supported by Hermes) instead of a prefix * regex, so strings that merely start with "http" but aren't valid URLs * are dropped. */ export function sanitizeHttpUrl(raw: unknown): string | null { if (typeof raw !== 'string' || !raw) return null; try { const parsed = new URL(raw); return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? raw : null; } catch { return null; } } /** Backend tool results are a raw string or {success, message}. */ export function resultText(results: SuperagentToolCall['results']): string { if (typeof results === 'string') return results; if (results && typeof results === 'object' && typeof results.message === 'string') return results.message; return ''; } /** Skip resolves the PSP tools as success — don't claim connected/selected. */ export function wasPspSkipped(results: SuperagentToolCall['results']): boolean { return /user skipped/i.test(resultText(results)); } /** Web parsePspToolErrorMessage parity, minus the runner's user-input prefix. */ export function parsePspToolErrorMessage(results: SuperagentToolCall['results']): string | null { let text: string | null = null; if (typeof results === 'string') { try { const parsed = JSON.parse(results) as { message?: unknown }; text = parsed && typeof parsed.message === 'string' ? parsed.message : results; } catch { text = results; } } else { text = resultText(results) || null; } if (!text) return null; return text.replace(/^tool call approved by the user\.\s*results:\s*/i, '').trim() || null; } /** * Whether the PSP widgets render their actionable form (web showForm parity: * `(waiting || error) && isLastAssistantMessage`). The backend keeps these * tools submittable after an error for in-place retry * (RETRY_IN_PLACE_ON_ERROR_TOOLS) — resubmitting reruns the same tool call — * so `error` keeps the form alongside `waiting`. Both are gated to the latest * assistant turn; a stale card falls back to the read-only status row. */ export function showsPspForm(status: ToolWidgetStatus, isLastAssistantMessage?: boolean): boolean { return (status === 'waiting' || status === 'error') && isActionableTurn(isLastAssistantMessage); } /** * Status row title for select_payment_provider (web statusLabel parity). * `stopped` means the call was rejected/stopped without resolving (the old * generic approval card's Reject, or another client) — it must not fall * through to "selected". `waiting` is the stale non-actionable card (not the * latest turn): the web shows the prompt label with the form gated off. */ export function selectPaymentProviderTitle( status: ToolWidgetStatus, results: SuperagentToolCall['results'], ): string { switch (status) { case 'waiting': return 'Choose a payment provider'; case 'running': return 'Selecting payment provider'; case 'error': return "Couldn't select payment provider"; case 'stopped': return 'Payment provider not selected'; default: return wasPspSkipped(results) ? 'Provider selection skipped' : 'Payment provider selected'; } } /** Status row title for configure_psp_credentials — same mapping as above. */ export function configurePspCredentialsTitle( status: ToolWidgetStatus, results: SuperagentToolCall['results'], processorName: string, ): string { switch (status) { case 'waiting': return `Connect ${processorName}`; case 'running': return `Connecting ${processorName}`; case 'error': return `Couldn't connect ${processorName}`; case 'stopped': return 'Payment setup not completed'; default: return wasPspSkipped(results) ? 'Payment setup skipped' : `${processorName} connected`; } } /** * Backend rejection messages embed LLM directives — substitute clean copy for * the user (web useConfigurePspCredentials parity). */ export function pspCredentialsErrorMessage( results: SuperagentToolCall['results'], processorName: string, ): string | null { const base = parsePspToolErrorMessage(results); if (!base) return null; if (/rejected by the payment provider/i.test(base)) { return ( `The ${processorName} credentials were rejected by the payment provider. ` + "You can complete the connection from the Payments dashboard " + "(the Payments button in your app's dashboard) when you have the correct details." ); } return base; }