356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Features } from '@/types';
|
|
import type { EnableBankingInstitution } from '@/types/banking';
|
|
import { __ } from '@/utils/i18n';
|
|
|
|
/**
|
|
* Single source of truth for the API-key banking providers.
|
|
*
|
|
* Every provider used to be branched on in ~9 places across the connect
|
|
* dialog, the inline connect flow and the update-credentials dialog. They are
|
|
* now described once here: the registry drives the institution list, the
|
|
* request endpoint/body, the on-screen copy and the credential form. Adding a
|
|
* provider is a single entry; consent-based EnableBanking has no entry and is
|
|
* handled as the default redirect flow by the consumers.
|
|
*/
|
|
|
|
type CredentialField = {
|
|
/** Doubles as the POST body key and the form-state key. */
|
|
key: string;
|
|
/** i18n key for the field label. */
|
|
label: string;
|
|
type: 'password' | 'text' | 'textarea';
|
|
/** i18n key for a translated placeholder. */
|
|
placeholder?: string;
|
|
/** Literal, non-translatable placeholder (e.g. an example value). */
|
|
placeholderExample?: string;
|
|
mono?: boolean;
|
|
small?: boolean;
|
|
};
|
|
|
|
export type ConnectProvider = {
|
|
/** `banking_connections.provider` value. */
|
|
providerKey: string;
|
|
institution: EnableBankingInstitution;
|
|
/** Connect endpoint (the update flow always PATCHes the connection). */
|
|
endpoint: string;
|
|
/** Whether the connect request also sends the selected `country`. */
|
|
sendsCountry?: boolean;
|
|
/** Only offered when connecting from this country (e.g. Indexa: ES). */
|
|
onlyCountry?: string;
|
|
/** Hidden unless this Pennant feature is active. */
|
|
feature?: keyof Features;
|
|
/** Confirm-step header copy (i18n key). */
|
|
headerDescription: string;
|
|
/** Confirm-card copy (i18n key). */
|
|
cardDescription: string;
|
|
fields: CredentialField[];
|
|
help: { before: string; href: string; link: string; after?: string };
|
|
};
|
|
|
|
export const CONNECT_PROVIDERS: ConnectProvider[] = [
|
|
{
|
|
providerKey: 'indexacapital',
|
|
institution: {
|
|
name: 'Indexa Capital',
|
|
country: 'ES',
|
|
logo: '/images/banks/logos/indexa-capital.jpg',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/indexa-capital/connect',
|
|
onlyCountry: 'ES',
|
|
headerDescription:
|
|
'Enter your API token to connect your Indexa Capital account.',
|
|
cardDescription:
|
|
'Connect your Indexa Capital account using your API token.',
|
|
fields: [
|
|
{
|
|
key: 'api_token',
|
|
label: 'API Token',
|
|
type: 'password',
|
|
placeholder: 'Paste your Indexa Capital API token',
|
|
},
|
|
],
|
|
help: {
|
|
before: 'You can generate your API token from your Indexa Capital dashboard under',
|
|
href: 'https://indexacapital.com/es/u/user#settings-apps',
|
|
link: 'Settings > Applications',
|
|
},
|
|
},
|
|
{
|
|
providerKey: 'binance',
|
|
institution: {
|
|
name: 'Binance',
|
|
country: 'ALL',
|
|
logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/binance/connect',
|
|
sendsCountry: true,
|
|
headerDescription:
|
|
'Enter your API Key and Secret to connect your Binance account.',
|
|
cardDescription:
|
|
'Connect your Binance account using your API Key and Secret.',
|
|
fields: [
|
|
{
|
|
key: 'api_key',
|
|
label: 'API Key',
|
|
type: 'password',
|
|
placeholder: 'Paste your Binance API Key',
|
|
},
|
|
{
|
|
key: 'api_secret',
|
|
label: 'API Secret',
|
|
type: 'password',
|
|
placeholder: 'Paste your Binance API Secret',
|
|
},
|
|
],
|
|
help: {
|
|
before: 'You can create API keys from your Binance account under',
|
|
href: 'https://www.binance.com/es/my/settings/api-management',
|
|
link: 'API Management',
|
|
},
|
|
},
|
|
{
|
|
providerKey: 'bitpanda',
|
|
institution: {
|
|
name: 'Bitpanda',
|
|
country: 'ALL',
|
|
logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/bitpanda/connect',
|
|
sendsCountry: true,
|
|
headerDescription:
|
|
'Enter your API Key to connect your Bitpanda account.',
|
|
cardDescription: 'Connect your Bitpanda account using your API Key.',
|
|
fields: [
|
|
{
|
|
key: 'api_key',
|
|
label: 'API Key',
|
|
type: 'password',
|
|
placeholder: 'Paste your Bitpanda API Key',
|
|
},
|
|
],
|
|
help: {
|
|
before: 'You can create API keys from your Bitpanda account under',
|
|
href: 'https://web.bitpanda.com/apikey',
|
|
link: 'API Key Management',
|
|
},
|
|
},
|
|
{
|
|
providerKey: 'coinbase',
|
|
institution: {
|
|
name: 'Coinbase',
|
|
country: 'ALL',
|
|
logo: 'https://whisper.money/storage/banks/logos/coinbase.png',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/coinbase/connect',
|
|
sendsCountry: true,
|
|
headerDescription:
|
|
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
|
|
cardDescription: 'Connect your Coinbase account using a CDP API key.',
|
|
fields: [
|
|
{
|
|
key: 'api_key_name',
|
|
label: 'App Key ID',
|
|
type: 'text',
|
|
placeholderExample: '00000000-0000-0000-0000-000000000000',
|
|
mono: true,
|
|
small: true,
|
|
},
|
|
{
|
|
key: 'private_key',
|
|
label: 'Secret',
|
|
type: 'textarea',
|
|
placeholderExample: 'Paste your CDP API key secret',
|
|
mono: true,
|
|
small: true,
|
|
},
|
|
],
|
|
help: {
|
|
before: 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
|
|
href: 'https://portal.cdp.coinbase.com/access/api',
|
|
link: 'API Keys',
|
|
after: 'Use a view-only key.',
|
|
},
|
|
},
|
|
{
|
|
providerKey: 'wise',
|
|
institution: {
|
|
name: 'Wise',
|
|
country: 'ALL',
|
|
logo: '/images/banks/logos/wise.png',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/wise/connect',
|
|
headerDescription:
|
|
'Enter your Wise Personal API token to connect your account.',
|
|
cardDescription:
|
|
'Connect your Wise account using a Personal API token.',
|
|
fields: [
|
|
{
|
|
key: 'api_token',
|
|
label: 'Personal API Token',
|
|
type: 'password',
|
|
placeholder: 'Paste your Wise API token',
|
|
},
|
|
],
|
|
help: {
|
|
before: 'Generate a token in Wise under',
|
|
href: 'https://wise.com/user/account#/developer',
|
|
link: 'Settings → Developer Tools → API tokens',
|
|
},
|
|
},
|
|
{
|
|
providerKey: 'interactivebrokers',
|
|
institution: {
|
|
name: 'Interactive Brokers',
|
|
country: 'ALL',
|
|
logo: '/images/banks/logos/interactive-brokers.png',
|
|
maximum_consent_validity: null,
|
|
},
|
|
endpoint: '/open-banking/interactive-brokers/connect',
|
|
feature: 'interactiveBrokers',
|
|
headerDescription:
|
|
'Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.',
|
|
cardDescription:
|
|
'Connect your Interactive Brokers account using a Flex Web Service token and Query ID.',
|
|
fields: [
|
|
{
|
|
key: 'token',
|
|
label: 'Flex Web Service Token',
|
|
type: 'password',
|
|
placeholder: 'Paste your Flex Web Service token',
|
|
},
|
|
{
|
|
key: 'query_id',
|
|
label: 'Flex Query ID',
|
|
type: 'text',
|
|
placeholderExample: '123456',
|
|
mono: true,
|
|
},
|
|
],
|
|
help: {
|
|
before: 'In Client Portal, create an Activity Flex Query including the "Net Asset Value (NAV)" and "Open Positions" sections, then generate a Flex Web Service token under',
|
|
href: 'https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm',
|
|
link: 'Performance & Reports → Flex Queries',
|
|
},
|
|
},
|
|
];
|
|
|
|
/** Find a provider by the selected institution name. */
|
|
export function connectProviderForBank(
|
|
name: string | undefined,
|
|
): ConnectProvider | undefined {
|
|
return CONNECT_PROVIDERS.find((p) => p.institution.name === name);
|
|
}
|
|
|
|
/** Find a provider by its `banking_connections.provider` value. */
|
|
export function connectProviderByKey(
|
|
providerKey: string,
|
|
): ConnectProvider | undefined {
|
|
return CONNECT_PROVIDERS.find((p) => p.providerKey === providerKey);
|
|
}
|
|
|
|
/** The credential payload (field values), shared by connect and update. */
|
|
export function credentialPayload(
|
|
provider: ConnectProvider,
|
|
values: Record<string, string>,
|
|
): Record<string, string> {
|
|
return Object.fromEntries(
|
|
provider.fields.map((f) => [f.key, values[f.key] ?? '']),
|
|
);
|
|
}
|
|
|
|
/** Whether every credential field has been filled in. */
|
|
export function isProviderComplete(
|
|
provider: ConnectProvider,
|
|
values: Record<string, string>,
|
|
): boolean {
|
|
return provider.fields.every((f) => (values[f.key] ?? '').length > 0);
|
|
}
|
|
|
|
export function ProviderHelp({ help }: { help: ConnectProvider['help'] }) {
|
|
return (
|
|
<p className="text-xs text-muted-foreground">
|
|
{__(help.before)}{' '}
|
|
<a
|
|
href={help.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="underline"
|
|
>
|
|
{__(help.link)}
|
|
</a>
|
|
{help.after ? <>. {__(help.after)}</> : '.'}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders a provider's credential inputs, bound to a flat values record.
|
|
* `idPrefix` keeps element ids unique between the connect and update dialogs.
|
|
*/
|
|
export function ProviderCredentialFields({
|
|
provider,
|
|
values,
|
|
onChange,
|
|
idPrefix = 'connect',
|
|
}: {
|
|
provider: ConnectProvider;
|
|
values: Record<string, string>;
|
|
onChange: (key: string, value: string) => void;
|
|
idPrefix?: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{provider.fields.map((field) => {
|
|
const id = `${idPrefix}-${provider.providerKey}-${field.key}`;
|
|
const placeholder =
|
|
field.placeholderExample ??
|
|
(field.placeholder ? __(field.placeholder) : undefined);
|
|
const className = cn(
|
|
'mt-1',
|
|
field.mono && 'font-mono',
|
|
field.small && 'text-xs',
|
|
);
|
|
|
|
return (
|
|
<div key={field.key} className="space-y-2">
|
|
<Label htmlFor={id}>{__(field.label)}</Label>
|
|
{field.type === 'textarea' ? (
|
|
<Textarea
|
|
id={id}
|
|
value={values[field.key] ?? ''}
|
|
onChange={(e) =>
|
|
onChange(field.key, e.target.value)
|
|
}
|
|
rows={6}
|
|
className={className}
|
|
placeholder={placeholder}
|
|
/>
|
|
) : (
|
|
<Input
|
|
id={id}
|
|
type={field.type}
|
|
value={values[field.key] ?? ''}
|
|
onChange={(e) =>
|
|
onChange(field.key, e.target.value)
|
|
}
|
|
className={className}
|
|
placeholder={placeholder}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
<ProviderHelp help={provider.help} />
|
|
</div>
|
|
);
|
|
}
|