export interface RibbitConnectTokens { customerToken: string accountToken: string institutionName: string accountMask: string bankId?: number | string } function parseEventRecord(eventData: unknown): Record { if (typeof eventData === 'string') { try { return JSON.parse(eventData) as Record } catch { return {} } } return (eventData ?? {}) as Record } function readField(record: Record, ...keys: string[]): unknown { for (const key of keys) { const value = record[key] if (value !== undefined && value !== null && value !== '') { return value } } const normalizedKeys = new Set(keys.map((key) => key.toLowerCase())) for (const [key, value] of Object.entries(record)) { if (normalizedKeys.has(key.toLowerCase()) && value !== undefined && value !== null && value !== '') { return value } } return undefined } /** * Normalize CONNECT account mask values to the last 4 digits. */ export function extractAccountLast4(value: unknown): string { if (value === undefined || value === null) { return '' } const raw = String(value).trim() if (!raw) { return '' } if (/^\d{4}$/.test(raw)) { return raw } const trailingDigits = raw.match(/(\d{4})\D*$/) if (trailingDigits) { return trailingDigits[1] } const digitsOnly = raw.replace(/\D/g, '') if (digitsOnly.length >= 4) { return digitsOnly.slice(-4) } return '' } function resolveAccountRecord( data: Record, enrollment: Record, ): Record { const accounts = [data.accounts, enrollment.accounts].find(Array.isArray) as unknown[] | undefined const accountFromList = accounts?.find((entry) => entry && typeof entry === 'object') ?? undefined return { ...(accountFromList && typeof accountFromList === 'object' ? (accountFromList as Record) : {}), ...((data.selectedAccount ?? enrollment.selectedAccount ?? {}) as Record), ...((data.account ?? enrollment.account ?? {}) as Record), } } function resolveAccountMask( data: Record, enrollment: Record, account: Record, ): string { const candidates = [ readField(account, 'last4Digits', 'last4digits', 'last4', 'lastFour', 'lastFourDigits', 'mask'), readField(account, 'accountNumber', 'accountNumberMask', 'displayAccountNumber', 'accountMask'), readField(data, 'last4Digits', 'last4digits', 'last4', 'lastFour', 'lastFourDigits', 'mask', 'accountMask'), readField(enrollment, 'last4Digits', 'last4digits', 'last4', 'lastFour', 'lastFourDigits', 'mask', 'accountMask'), readField(data, 'accountNumber', 'accountNumberMask', 'displayAccountNumber'), readField(enrollment, 'accountNumber', 'accountNumberMask', 'displayAccountNumber'), ] for (const candidate of candidates) { const last4 = extractAccountLast4(candidate) if (last4) { return last4 } } return '' } /** * Parse Ribbit CONNECT completion payload defensively. * Matches Green Club RibbitWidget parsing (nested account + enrollment shapes). */ export function parseConnectEventData(eventData: unknown): RibbitConnectTokens { const raw = parseEventRecord(eventData) const data = (raw.data ?? raw.payload ?? raw) as Record const enrollment = (data.enrollmentData ?? data.enrollment ?? data) as Record const account = resolveAccountRecord(data, enrollment) const customerToken = String( readField(data, 'customerId', 'customerToken') ?? readField(enrollment, 'customerId', 'customerToken') ?? readField(account, 'customerId', 'customerToken') ?? '', ) const accountToken = String( readField(data, 'accountToken', 'accountId') ?? readField(enrollment, 'accountToken', 'accountId') ?? readField(account, 'accountToken', 'accountId', 'id') ?? '', ) const institutionName = String( readField(account, 'bankName', 'institutionName') ?? readField(data, 'institutionName', 'bankName') ?? readField(enrollment, 'institutionName', 'bankName') ?? '', ) const accountMask = resolveAccountMask(data, enrollment, account) const bankIdRaw = readField(account, 'bankId', 'BankId') ?? readField(data, 'bankId', 'BankId') ?? readField(enrollment, 'bankId', 'BankId') const bankId = bankIdRaw !== undefined && bankIdRaw !== null && String(bankIdRaw).trim() !== '' ? bankIdRaw : undefined return { customerToken, accountToken, institutionName, accountMask, bankId } }