Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/components/Global/SupportDrawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ const SupportDrawer = () => {
if (userData.userId) {
CapacitorCrisp.setString({ key: 'user_id', value: userData.userId })
}
// live verification state so agents stop guessing (#2360). Always
// write (empty string when absent) so a prior user's values can't
// linger on the device-local Crisp session — matching web/proxy.
CapacitorCrisp.setString({ key: 'identity_status', value: userData.identityStatus || '' })
CapacitorCrisp.setString({
key: 'email_on_file',
value: userData.emailOnFile === undefined ? '' : userData.emailOnFile ? 'yes' : 'no',
})
CapacitorCrisp.setString({ key: 'verification_gates', value: userData.verificationGates || '' })
CapacitorCrisp.setString({ key: 'verification_rails', value: userData.verificationRails || '' })
CapacitorCrisp.setString({ key: 'failure_reason', value: userData.failureReason || '' })
CapacitorCrisp.setString({ key: 'pending_actions', value: userData.pendingActions || '' })
if (prefilledMessage) {
CapacitorCrisp.sendMessage({ value: prefilledMessage })
}
Expand Down
21 changes: 20 additions & 1 deletion src/hooks/useCrispUserData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildSupportVerificationSummary } from '@/utils/support-verification'
import { useAuth } from '@/context/authContext'
import { AccountType } from '@/interfaces/interfaces'
import { useMemo } from 'react'
Expand All @@ -14,6 +15,13 @@ export interface CrispUserData {
bridgeCustomerLink: string | undefined
mantecaUserId: string | undefined
posthogPersonLink: string | undefined
// Live verification state so agents stop guessing where a user is stuck (#2360).
identityStatus: string | undefined
emailOnFile: boolean | undefined
verificationGates: string | undefined
verificationRails: string | undefined
failureReason: string | undefined
pendingActions: string | undefined
}

/**
Expand Down Expand Up @@ -44,17 +52,28 @@ export function useCrispUserData(): CrispUserData {

const posthogPersonLink = userId ? `${POSTHOG_PERSON_BASE_URL}/${userId}` : undefined

const email = user?.user?.email || undefined
const verification = user
? buildSupportVerificationSummary(user.capabilities, user.identityVerification, email)
: undefined

return {
username,
userId,
email: user?.user?.email || undefined,
email,
fullName: user?.user?.fullName,
avatar: user?.user?.profile_picture || undefined,
walletAddress,
walletAddressLink,
bridgeCustomerLink,
mantecaUserId,
posthogPersonLink,
identityStatus: verification?.identityStatus,
emailOnFile: verification?.emailOnFile,
verificationGates: verification?.gates,
verificationRails: verification?.verificationRails,
failureReason: verification?.failureReason,
pendingActions: verification?.pendingActions,
}
}, [username, userId, user])
}
116 changes: 116 additions & 0 deletions src/utils/__tests__/support-verification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { buildSupportVerificationSummary } from '../support-verification'
import type { IdentityVerification, RailCapability, UserCapabilities } from '@/types/capabilities'

function caps(rails: RailCapability[], nextActions: UserCapabilities['nextActions'] = []): UserCapabilities {
return { rails, nextActions, restrictions: [] }
}

const enabledRail: RailCapability = {
id: 'bridge.ach_us',
provider: 'bridge',
method: 'ACH_US',
channel: 'bank',
country: 'US',
currency: 'USD',
status: 'enabled',
resolved: { status: 'enabled' },
}

const emailBlockedRail: RailCapability = {
id: 'manteca.pix_br',
provider: 'manteca',
method: 'PIX_BR',
channel: 'bank',
country: 'BR',
currency: 'BRL',
status: 'blocked',
reason: {
code: 'no_email_captured',
userMessage: 'We need your email',
details: 'No email captured during submission',
},
resolved: {
status: 'fixable',
blocking: {
code: 'no_email_captured',
userMessage: 'We need your email',
selfHealable: true,
selfHealKind: 'provide-email',
details: 'No email captured during submission',
},
},
}

describe('buildSupportVerificationSummary', () => {
test('reports identity status, email-on-file and per-op gates', () => {
const identity: IdentityVerification = { status: 'verified' }
const summary = buildSupportVerificationSummary(caps([enabledRail]), identity, 'a@b.com')

expect(summary.identityStatus).toBe('verified')
expect(summary.emailOnFile).toBe(true)
expect(summary.gates).toContain('pay:ready')
})

test('surfaces the stuck rail + failure reason', () => {
const identity: IdentityVerification = { status: 'verified' }
const summary = buildSupportVerificationSummary(caps([emailBlockedRail]), identity, undefined)

expect(summary.emailOnFile).toBe(false)
expect(summary.failureReason).toBe('manteca.pix_br · no_email_captured — No email captured during submission')
expect(summary.gates).toContain('deposit:provide-email')
expect(summary.verificationRails).toBe('manteca.pix_br:fixable(no_email_captured)')
})

test('names the rail behind a pending/waiting gate that has no failure reason', () => {
const waitingRail: RailCapability = {
id: 'manteca.bank_transfer_ar',
provider: 'manteca',
method: 'BANK_TRANSFER_AR',
channel: 'bank',
country: 'AR',
currency: 'ARS',
status: 'pending',
resolved: { status: 'pending' },
}
const summary = buildSupportVerificationSummary(caps([waitingRail]), { status: 'verified' }, 'a@b.com')

// no failure to report, but the agent must still see WHICH rail is stuck
expect(summary.failureReason).toBeUndefined()
expect(summary.verificationRails).toBe('manteca.bank_transfer_ar:pending')
})

test('lists pending next-actions as kind(purpose)', () => {
const summary = buildSupportVerificationSummary(
caps([emailBlockedRail], [{ key: 'k1', kind: 'provide-email', purpose: 'unlock-manteca-pix' }]),
{ status: 'verified' },
undefined
)
expect(summary.pendingActions).toBe('provide-email(unlock-manteca-pix)')
})

test('degrades cleanly with no read-models', () => {
const summary = buildSupportVerificationSummary(undefined, undefined, undefined)
expect(summary.identityStatus).toBe('unknown')
expect(summary.emailOnFile).toBe(false)
// no capability read-model — must NOT read as "needs-identity on everything",
// which an agent can't tell apart from a genuinely unverified user
expect(summary.gates).toBe('')
expect(summary.verificationRails).toBeUndefined()
expect(summary.failureReason).toBeUndefined()
expect(summary.pendingActions).toBeUndefined()
})

test('still reports gates for a user whose read-model is present but empty', () => {
const summary = buildSupportVerificationSummary(caps([]), undefined, undefined)
expect(summary.gates).toBe('pay:needs-identity deposit:needs-identity withdraw:needs-identity')
})

test('reports action_required identity status', () => {
const summary = buildSupportVerificationSummary(
caps([enabledRail]),
{ status: 'action_required', actionMessage: 'Re-upload your document' },
'a@b.com'
)
expect(summary.identityStatus).toBe('action_required')
})
})
12 changes: 12 additions & 0 deletions src/utils/crisp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export function setCrispUserData(
bridgeCustomerLink,
mantecaUserId,
posthogPersonLink,
identityStatus,
emailOnFile,
verificationGates,
verificationRails,
failureReason,
pendingActions,
} = userData

if (email) {
Expand Down Expand Up @@ -59,6 +65,12 @@ export function setCrispUserData(
['bridge_user_id', bridgeCustomerLink || ''],
['manteca_user_id', mantecaUserId || ''],
['posthog_person', posthogPersonLink || ''],
['identity_status', identityStatus || ''],
['email_on_file', emailOnFile === undefined ? '' : emailOnFile ? 'yes' : 'no'],
['verification_gates', verificationGates || ''],
['verification_rails', verificationRails || ''],
['failure_reason', failureReason || ''],
['pending_actions', pendingActions || ''],
],
],
])
Expand Down
82 changes: 82 additions & 0 deletions src/utils/support-verification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Support-facing verification snapshot — the state a Crisp agent needs to stop
* guessing where a user is stuck (issue #2360). Reads the two backend read-models
* already on the /get-user response (`capabilities`, `identityVerification`) and
* the on-file email; derives nothing the FE doesn't already render for the user.
*
* Everything here is pushed into Crisp `session:data` — the agent sidebar, which
* only the support agent sees. The user's own message is never touched.
*/

import { deriveGate, railVerdict } from '@/utils/capability-gate'
import type { IdentityVerification, RailOperation, UserCapabilities } from '@/types/capabilities'

const SUMMARY_OPERATIONS: RailOperation[] = ['pay', 'deposit', 'withdraw']

export interface SupportVerificationSummary {
/** identityVerification.status, or 'unknown' when the read-model is absent. */
identityStatus: string
/** whether an email is on file — provider submission can't run without one. */
emailOnFile: boolean
/** per-operation gate kinds, e.g. "pay:ready deposit:provide-email withdraw:blocked-rejection". */
gates: string
/** every non-enabled rail as "id:status(reasonCode)" — names which rail is stuck, even for pending/waiting gates. */
verificationRails?: string
/** the stuck rail's id + normalized reason code + technical details, if any. */
failureReason?: string
/** pending next-actions as "kind(purpose)", comma-joined. */
pendingActions?: string
}

export function buildSupportVerificationSummary(
capabilities: UserCapabilities | undefined,
identityVerification: IdentityVerification | undefined,
email: string | undefined
): SupportVerificationSummary {
const rails = capabilities?.rails ?? []
const nextActions = capabilities?.nextActions ?? []
const identityStatus = identityVerification?.status ?? 'unknown'
const identityVerified = identityStatus === 'verified'
const emailOnFile = Boolean(email)

// An absent capability read-model is NOT a verified-identity signal: deriving
// over the empty state would report `needs-identity` on every op, which an
// agent cannot tell apart from a genuinely unverified user. Report nothing.
const gateState = { rails, nextActions, identityVerified, isLoading: false }
const gates = capabilities
? SUMMARY_OPERATIONS.map((op) => `${op}:${deriveGate(gateState, op).kind}`).join(' ')
: ''

// Per-rail verdicts. `gates` gives the op-level kind but not the rail; this
// names every rail that isn't clear (blocked/fixable/pending/requires-info)
// so an agent can see WHICH rail is stuck even for pending/waiting gates,
// where there's no failureReason to fall back on.
const actionByKey = new Map(nextActions.map((action) => [action.key, action]))
const railStates = rails
.map((rail) => ({ rail, verdict: railVerdict(rail, actionByKey) }))
.filter(({ verdict }) => verdict.status !== 'enabled')

const verificationRails = railStates.length
? railStates
.map(
({ rail, verdict }) =>
`${rail.id}:${verdict.status}${verdict.blocking?.code ? `(${verdict.blocking.code})` : ''}`
)
.join(' ')
: undefined

// The one blocker worth its technical detail — provider `details` (e.g.
// "No email captured…") is what the support agent actually needs.
const blocked = railStates.find(({ verdict }) => verdict.status === 'blocked' || verdict.status === 'fixable')
let failureReason: string | undefined
if (blocked?.verdict.blocking) {
const { code, details } = blocked.verdict.blocking
failureReason = `${blocked.rail.id} · ${code}${details ? ` — ${details}` : ''}`
}

const pendingActions = nextActions.length
? nextActions.map((action) => `${action.kind}(${action.purpose})`).join(', ')
: undefined

return { identityStatus, emailOnFile, gates, verificationRails, failureReason, pendingActions }
}
Loading