From 0fbb81e4aab04f2b943bc005d156e402466bf8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:15:04 -0300 Subject: [PATCH 1/4] feat(card): proof-of-address upload on stuck Rain applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card status screens for requires-info/rejected only offered "Contact support", even when the backend classified the application as PoA-fixable (residence vs POI country mismatch, the digital-nomad case that drives most manual card support). When the rain rail carries the sumsub:proof_of_address next-action, the status screen now renders a primary "Upload proof of address" CTA that opens the Sumsub RFI flow via the self-heal machinery (provider RAIN on /users/identity/resubmit — peanut-api-ts#1194 is the backend half). On completion the rain overview refetches and the backend flips the rail reason to "document received - being reviewed". The self-heal flow gets its own SumsubKycModals surface — the existing SumsubKycWrapper on this page is bound to card-apply tokens with their own refresh/poll semantics and must not be multiplexed. --- src/app/(mobile-ui)/card/page.tsx | 31 +++++++++- src/app/actions/sumsub.ts | 2 +- .../Card/ApplicationStatusScreen.tsx | 18 +++++- .../ApplicationStatusScreen.test.tsx | 39 ++++++++++++ src/hooks/useSumsubKycFlow.ts | 59 ++++++++++--------- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 17e3062aa2..27be3da591 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -21,6 +21,8 @@ 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 { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { rainApi, type ApplyForCardResponse } from '@/services/rain' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { useCapabilities } from '@/hooks/useCapabilities' @@ -69,7 +71,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') @@ -212,6 +214,30 @@ const CardPage: FC = () => { void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) }, [queryClient]) + // Self-heal Sumsub flow for the requires-info/rejected states (e.g. the + // proof-of-address upload). Separate surface from the card-application + // SumsubKycWrapper below — that one is driven by applyForCard tokens with + // its own refresh/poll semantics; multiplexing them would fight over the + // token. On completion the overview refetches so the screen advances. + const selfHealKycFlow = useMultiPhaseKycFlow({ onKycSuccess: invalidateOverview }) + + // 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 + const onUploadProofOfAddress = poaAction + ? () => { + posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED) + void selfHealKycFlow.handleSelfHealResubmit('RAIN') + } + : undefined + // 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. @@ -605,6 +631,7 @@ const CardPage: FC = () => { variant="requires-info" reasonMessage={cardRailReason} onContactSupport={() => setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} onPrev={onBack} /> ) @@ -641,6 +668,7 @@ const CardPage: FC = () => { variant="rejected" reasonMessage={cardRailReason} onContactSupport={() => setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} onPrev={onBack} /> ) @@ -657,6 +685,7 @@ const CardPage: FC = () => { return ( {renderState()} + 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 onPrev?: () => void } @@ -46,7 +51,13 @@ const COPY: Record = { /** Variants where support is the only path forward — these render the CTA. */ const SUPPORT_VARIANTS: ReadonlySet = new Set(['requires-info', 'requires-support', 'rejected']) -const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactSupport, onPrev }) => { +const ApplicationStatusScreen: FC = ({ + variant, + reasonMessage, + onContactSupport, + onUploadProofOfAddress, + onPrev, +}) => { const copy = COPY[variant] return (
@@ -69,6 +80,11 @@ const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactS {reasonMessage &&

{reasonMessage}

}

{copy.body}

+ {SUPPORT_VARIANTS.has(variant) && onUploadProofOfAddress && ( + + )} {SUPPORT_VARIANTS.has(variant) && onContactSupport && ( +
+ + {uploadError &&

{uploadError}

} +
)} {SUPPORT_VARIANTS.has(variant) && onContactSupport && (