Skip to content
Closed
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
94 changes: 89 additions & 5 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Loading from '@/components/Global/Loading'
import { Button } from '@/components/0_Bruddle/Button'
import PageContainer from '@/components/0_Bruddle/PageContainer'
import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper'
import { initiateSelfHealResubmission } from '@/app/actions/sumsub'
import { rainApi, type ApplyForCardResponse } from '@/services/rain'
import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey'
import { useCapabilities } from '@/hooks/useCapabilities'
Expand Down Expand Up @@ -69,7 +70,7 @@ const CardPage: FC = () => {

const { overview, isLoading: overviewLoading, error: overviewError } = useRainCardOverview()
const { serializeGrant } = useGrantSessionKey()
const { railsForProvider, isLoading: capabilitiesLoading } = useCapabilities()
const { railsForProvider, nextActionsForRail, isLoading: capabilitiesLoading } = useCapabilities()
const { setIsSupportModalOpen } = useModalsContext()
const onBack = useSafeBack('/home')

Expand Down Expand Up @@ -212,6 +213,62 @@ const CardPage: FC = () => {
void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] })
}, [queryClient])

// Proof-of-address self-heal — a dedicated, deliberately tiny flow. The
// multi-phase KYC machinery (useMultiPhaseKycFlow) is bank-onboarding
// shaped: its post-approval phase machine polls a mutating endpoint, can
// fan out to Bridge ToS modals, and completes on rail semantics that never
// match the Rain PoA lifecycle. Here we only need: mint an action token →
// open the SDK → on submit, thank the user and refetch. Separate surface
// from the card-application SumsubKycWrapper below — that one is driven by
// applyForCard tokens with its own refresh/poll semantics.
const [poaToken, setPoaToken] = useState<string | null>(null)
const [poaError, setPoaError] = useState<string | null>(null)
// Optimistic "we got your document" until the backend webhook flips the
// rail reason to its own review-wait state (may lag the SDK by seconds).
const [poaSubmitted, setPoaSubmitted] = useState(false)

// The rain rail's self-serve proof-of-address action, when the backend
// classified the application as PoA-fixable (kind 'sumsub' + levelKey
// 'proof_of_address' — emitted for WRONG_ADDRESS-class denials). Absent →
// the status screens keep their contact-support-only shape.
const cardRail = railsForProvider('rain')[0]
const poaAction = cardRail
? nextActionsForRail(cardRail.id).find(
(action) => action.kind === 'sumsub' && action.levelKey === 'proof_of_address'
)
: undefined
// Double-click guard: two concurrent initiations race the backend's
// create-action idempotency into minting two Sumsub actions (the id
// collision path deliberately mints a suffixed fresh action).
const poaStartingRef = useRef(false)
const startPoaUpload = useCallback(async () => {
if (poaStartingRef.current) return
poaStartingRef.current = true
posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED, { source: 'poa-self-heal' })
setPoaError(null)
try {
const response = await initiateSelfHealResubmission('RAIN')
if (response.error || !response.data?.token) {
// Surfaced inline on the status screen — a silent primary CTA on
// a stuck-application screen is worse than no CTA.
setPoaError(response.error ?? 'Could not start the upload. Please try again.')
return
}
setPoaToken(response.data.token)
} finally {
poaStartingRef.current = false
}
}, [])
const onUploadProofOfAddress = poaAction && !poaSubmitted ? () => void startPoaUpload() : undefined

// Once the backend's own state takes over (the rail's sumsub action gives
// way to the review-wait state, an approval, or a different ask), drop the
// optimistic banner so a later re-offered upload isn't suppressed until
// remount.
useEffect(() => {
if (poaSubmitted && !poaAction) setPoaSubmitted(false)
}, [poaSubmitted, poaAction])

// Routes a non-incomplete apply response to the right next screen. Shared
// by the user-initiated apply path and the post-Sumsub poll, since both
// need the same main-kyc-required / terms-required / default fan-out.
Expand Down Expand Up @@ -599,12 +656,16 @@ const CardPage: FC = () => {
</div>
)
}
const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage
const cardRailReason = poaSubmitted
? 'We received your proof of address — it’s being reviewed.'
: railsForProvider('rain')[0]?.reason?.userMessage
return (
<ApplicationStatusScreen
variant="requires-info"
reasonMessage={cardRailReason}
onContactSupport={() => setIsSupportModalOpen(true)}
onUploadProofOfAddress={onUploadProofOfAddress}
uploadError={poaError ?? undefined}
onPrev={onBack}
/>
)
Expand Down Expand Up @@ -633,14 +694,18 @@ const CardPage: FC = () => {
// (meaningless without its reason), the rejected screen is useful
// on its own (reassurance + support CTA), so show it now and let
// the reason fill in once capabilities resolve.
const cardRailReason = capabilitiesLoading
? undefined
: railsForProvider('rain')[0]?.reason?.userMessage
const cardRailReason = poaSubmitted
? 'We received your proof of address — it’s being reviewed.'
: capabilitiesLoading
? undefined
: railsForProvider('rain')[0]?.reason?.userMessage
return (
<ApplicationStatusScreen
variant="rejected"
reasonMessage={cardRailReason}
onContactSupport={() => setIsSupportModalOpen(true)}
onUploadProofOfAddress={onUploadProofOfAddress}
uploadError={poaError ?? undefined}
onPrev={onBack}
/>
)
Expand All @@ -657,6 +722,25 @@ const CardPage: FC = () => {
return (
<PageContainer>
{renderState()}
<SumsubKycWrapper
visible={poaToken !== null}
accessToken={poaToken}
onClose={() => setPoaToken(null)}
onComplete={() => {
// Document submitted to Sumsub. Review + the backend webhook
// stamp happen async — flip the optimistic banner and refetch
// so the screen picks up the backend's wait state when ready.
setPoaToken(null)
setPoaSubmitted(true)
invalidateOverview()
void fetchUser()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}}
onRefreshToken={async () => {
const response = await initiateSelfHealResubmission('RAIN')
if (!response.data?.token) throw new Error(response.error ?? 'Failed to refresh token')
return response.data.token
}}
/>
<SumsubKycWrapper
visible={sumsubToken !== null}
accessToken={sumsubToken}
Expand Down
4 changes: 2 additions & 2 deletions src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export interface SelfHealResubmissionResponse {
applicantId: string
actionId: string
externalActionId: string
requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT'
requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT' | 'RAIN_DOCUMENT'
userMessage: string
attempt: number
maxAttempts: number
Expand Down Expand Up @@ -86,7 +86,7 @@ export const restartIdentityVerification = async (): Promise<{

// initiate self-heal document resubmission for a provider-rejected user
export const initiateSelfHealResubmission = async (
provider: 'BRIDGE' | 'MANTECA',
provider: 'BRIDGE' | 'MANTECA' | 'RAIN',
// Optional — target a specific (e.g. future-dated advisory) Bridge requirement
// by key. Omitted for the legacy blocking flow (current nextAction).
requirementKey?: string
Expand Down
25 changes: 24 additions & 1 deletion src/components/Card/ApplicationStatusScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Image from 'next/image'
import { PeanutCrying } from '@/assets/mascot'
import NavHeader from '@/components/Global/NavHeader'
import Loading from '@/components/Global/Loading'
import { Button } from '@/components/0_Bruddle/Button'

type Variant = 'pending' | 'manual-review' | 'requires-info' | 'requires-support' | 'rejected'

Expand All @@ -14,6 +15,13 @@ interface Props {
* user sees what specifically is missing. Provider-neutral by contract. */
reasonMessage?: string
onContactSupport?: () => void
/** When the rail carries a self-serve proof-of-address action, this opens
* the Sumsub upload flow — rendered as the primary CTA so users fix it
* themselves instead of messaging support. */
onUploadProofOfAddress?: () => void
/** Inline failure from starting the upload (a silent primary CTA on a
* stuck-application screen reads as broken). */
uploadError?: string
onPrev?: () => void
}

Expand Down Expand Up @@ -46,7 +54,14 @@ const COPY: Record<Variant, { title: string; body: string }> = {
/** Variants where support is the only path forward — these render the CTA. */
const SUPPORT_VARIANTS: ReadonlySet<Variant> = new Set(['requires-info', 'requires-support', 'rejected'])

const ApplicationStatusScreen: FC<Props> = ({ variant, reasonMessage, onContactSupport, onPrev }) => {
const ApplicationStatusScreen: FC<Props> = ({
variant,
reasonMessage,
onContactSupport,
onUploadProofOfAddress,
uploadError,
onPrev,
}) => {
const copy = COPY[variant]
return (
<div className="flex min-h-[inherit] flex-col gap-8">
Expand All @@ -69,6 +84,14 @@ const ApplicationStatusScreen: FC<Props> = ({ variant, reasonMessage, onContactS
{reasonMessage && <p className="text-grey-1">{reasonMessage}</p>}
<p className="text-grey-1">{copy.body}</p>
</div>
{SUPPORT_VARIANTS.has(variant) && onUploadProofOfAddress && (
<div className="flex w-full flex-col gap-2">
<Button variant="purple" shadowSize="4" className="w-full" onClick={onUploadProofOfAddress}>
Upload proof of address
</Button>
{uploadError && <p className="text-sm text-error">{uploadError}</p>}
</div>
)}
{SUPPORT_VARIANTS.has(variant) && onContactSupport && (
<button type="button" onClick={onContactSupport} className="text-black underline">
Contact support
Expand Down
53 changes: 53 additions & 0 deletions src/components/Card/__tests__/ApplicationStatusScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,56 @@ describe('ApplicationStatusScreen — rejected', () => {
expect(onContactSupport).toHaveBeenCalledTimes(1)
})
})

describe('ApplicationStatusScreen — proof-of-address upload CTA', () => {
it('renders the upload CTA as primary when the rail carries a PoA action', () => {
const onUpload = jest.fn()
render(
<ApplicationStatusScreen
variant="requires-info"
reasonMessage="We need a valid proof of address document."
onContactSupport={jest.fn()}
onUploadProofOfAddress={onUpload}
/>
)
fireEvent.click(screen.getByText('Upload proof of address'))
expect(onUpload).toHaveBeenCalledTimes(1)
// Contact support stays available as the fallback path.
expect(screen.getByText('Contact support')).toBeInTheDocument()
})

it('renders the upload CTA on the rejected variant too (denied + fixable PoA)', () => {
render(
<ApplicationStatusScreen
variant="rejected"
onContactSupport={jest.fn()}
onUploadProofOfAddress={jest.fn()}
/>
)
expect(screen.getByText('Upload proof of address')).toBeInTheDocument()
})

it('omits the upload CTA when no PoA action exists', () => {
render(<ApplicationStatusScreen variant="requires-info" onContactSupport={jest.fn()} />)
expect(screen.queryByText('Upload proof of address')).not.toBeInTheDocument()
})

it('never renders the upload CTA on non-support variants', () => {
render(<ApplicationStatusScreen variant="pending" onUploadProofOfAddress={jest.fn()} />)
expect(screen.queryByText('Upload proof of address')).not.toBeInTheDocument()
})
})

describe('ApplicationStatusScreen — upload error', () => {
it('renders the inline error under the upload CTA', () => {
render(
<ApplicationStatusScreen
variant="requires-info"
onContactSupport={jest.fn()}
onUploadProofOfAddress={jest.fn()}
uploadError="Could not start the upload. Please try again."
/>
)
expect(screen.getByText('Could not start the upload. Please try again.')).toBeInTheDocument()
})
})
Loading