diff --git a/components/Card/NewCardDetails/CardActionsRow.tsx b/components/Card/NewCardDetails/CardActionsRow.tsx index 3f7681c7..6c018c44 100644 --- a/components/Card/NewCardDetails/CardActionsRow.tsx +++ b/components/Card/NewCardDetails/CardActionsRow.tsx @@ -5,7 +5,6 @@ import { router } from 'expo-router'; import { ShieldCheck } from 'lucide-react-native'; import CardDirectDepositModal from '@/components/Card/CardDirectDepositModal'; -import RegisterSpendAction from '@/components/Card/NewCardDetails/RegisterSpendAction'; import WithdrawToCardModal from '@/components/Card/WithdrawToCardModal'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -80,6 +79,12 @@ interface CardActionsRowProps { isFreezing: boolean; onFreezeToggle: () => void; onMorePress: () => void; + /** + * Opens the card-spending sheet. The sheet itself lives on the pane rather than in + * this row, because a blocked "Show details" tap opens the same one — and it has to + * stay reachable when this row hides its own button (a frozen card). + */ + onSpendPress: () => void; /** * Whether funds can move onto the card: not frozen, and KYC not paused or * offboarded. Derived by the parent (`canAddFundsToCard`) rather than here, so @@ -115,6 +120,7 @@ const CardActionsRow = ({ isFreezing, onFreezeToggle, onMorePress, + onSpendPress, canAddFunds, canWithdraw, }: CardActionsRowProps) => { @@ -136,17 +142,16 @@ const CardActionsRow = ({ {showRegister && ( - - - - } - /> + + + )} {showDeposit && ( diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index c08539cc..c2e4812a 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -21,17 +21,19 @@ import { getCardHeroDestination } from '@/components/Card/NewCardDetails/cardHer import CardLinksList from '@/components/Card/NewCardDetails/CardLinksList'; import CardRevealSection from '@/components/Card/NewCardDetails/CardRevealSection'; import { HERO_ENTER, HeroEnter } from '@/components/Card/NewCardDetails/heroMotion'; +import RegisterSpendAction from '@/components/Card/NewCardDetails/RegisterSpendAction'; import { usePageLeft } from '@/components/Navbar/Sidebar'; import CashbackDetailsSheet from '@/components/Rewards/NewRewards/CashbackDetailsSheet'; import { path } from '@/constants/path'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardProvider } from '@/hooks/useCardProvider'; +import { CardSpendRegistrationSource } from '@/hooks/useCardSpendRegistration'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useCustomer } from '@/hooks/useCustomer'; import { useRewardsUserData } from '@/hooks/useRewards'; import { freezeCard, unfreezeCard } from '@/lib/api'; import { resolveUserCashbackRate } from '@/lib/tierCashback'; -import { CardStatus } from '@/lib/types'; +import { CardProvider, CardStatus } from '@/lib/types'; import { canAddFundsToCard, canToggleCardFreeze, @@ -90,6 +92,19 @@ const CardDetailsPane = () => { const { provider } = useCardProvider(); const [isFreezing, setIsFreezing] = useState(false); const [isAddToWalletOpen, setIsAddToWalletOpen] = useState(false); + // The card-spending sheet is owned here rather than by the action row, because two + // things open it: the row's own "Set up"/"Spending" button, and a "Show details" tap + // on a card that cannot spend yet. One instance above both also keeps it reachable + // when the row hides its button (a frozen card) — which is exactly when a blocked + // reveal still needs somewhere to send the user. `null` is closed; the value it holds + // is which entry point opened it, for the registration funnel. + const [spendSheetSource, setSpendSheetSource] = useState( + null, + ); + // Stable identities: the reveal section folds its opener into the memoised toggle + // handler, which would be rebuilt on every render of this pane otherwise. + const openSpendSheet = useCallback(() => setSpendSheetSource('spending_sheet'), []); + const openSpendSheetFromReveal = useCallback(() => setSpendSheetSource('card_reveal'), []); // Held true through the dismissal, so the sections have something to animate out // of; without it `isOpen` going false would yank the pane off screen instantly. const [isSettling, setIsSettling] = useState(false); @@ -114,6 +129,10 @@ const CardDetailsPane = () => { scrollRef.current?.scrollTo({ y: 0, animated: false }); return; } + // A dismissed pane must not leave its spending sheet floating over the wallet: the + // dialog is portalled, so it outlives the layer that opened it. Clearing the source + // rather than merely hiding it also stops the sheet reappearing on the next visit. + setSpendSheetSource(null); // Nothing to settle if it was never opened, or the pane would sit visible for the // settle window on startup. if (!hasOpened.current) return; @@ -225,6 +244,7 @@ const CardDetailsPane = () => { last4={cardDetails?.card_details?.last_4} cardholderName={cardDetails?.cardholder_name} provider={provider} + onRequireSpendSetup={openSpendSheetFromReveal} // The card's own issuing country, falling back to the KYC residence // country the status endpoint reports (it's absent for test overrides). issuingCountryCode={cardDetails?.issuing_country ?? cardStatus?.country} @@ -236,6 +256,7 @@ const CardDetailsPane = () => { isFreezing={isFreezing} onFreezeToggle={handleFreezeToggle} onMorePress={() => setIsAddToWalletOpen(true)} + onSpendPress={openSpendSheet} canAddFunds={canAddFundsToCard(fundsAccess)} canWithdraw={canWithdrawFromCard(fundsAccess)} /> @@ -262,6 +283,16 @@ const CardDetailsPane = () => { isOpen={isOpen && shouldShowWelcomePopup} onClose={() => setShouldShowWelcomePopup(false)} /> + {/* Wirex only — a Rain card prefunds itself and has no module to enable. Mounted + on the issuer rather than on the action row's own visibility, so the reveal + gate above always has a sheet to open. */} + {provider === CardProvider.WIREX && ( + setSpendSheetSource(open ? 'spending_sheet' : null)} + source={spendSheetSource ?? 'spending_sheet'} + /> + )} void; } /** @@ -46,15 +53,24 @@ interface CardRevealSectionProps { * If the reveal request fails the card stays on its front face and the reason is * surfaced as a toast, so the toggle can be tried again. It must never flip onto * stand-in digits: that reads as a working card the user might try to spend. + * + * On a Wirex card the same rule extends to a card that cannot spend at all — see + * `isRevealBlocked`. */ const CardRevealSection = ({ last4, cardholderName, provider, issuingCountryCode, + onRequireSpendSetup, }: CardRevealSectionProps) => { const { cardDetails, isLoading, error, revealDetails, clearCardDetails } = useCardDetailsReveal(provider); + const { isRegistered: canCardSpend, isLoading: isSpendStateLoading } = useCardSpendRegistration(); + // A card whose Safe cannot be debited declines every payment, so its numbers are not + // worth revealing — the tap opens the sheet that fixes that instead. The rule itself, + // and why it fails closed on a read that has not landed, is in `canRevealCardDetails`. + const isRevealBlocked = !canRevealCardDetails({ provider, canCardSpend }); const isPaneOpen = useCardPaneStore(state => state.isOpen); const [isRevealed, setIsRevealed] = useState(false); const [hasRequested, setHasRequested] = useState(false); @@ -129,12 +145,18 @@ const CardRevealSection = ({ clearCardDetails(); return; } + // Never reached while the spend state is still loading — the toggle carries the + // spinner and is disabled until it is known. + if (isRevealBlocked) { + onRequireSpendSetup(); + return; + } setHasRequested(true); void revealDetails().catch(() => { // Swallowed: the hook surfaces the failure through `error`, which the effect // above turns into a toast. }); - }, [isRevealed, revealDetails, clearCardDetails]); + }, [isRevealed, isRevealBlocked, onRequireSpendSetup, revealDetails, clearCardDetails]); const copy = useCallback((label: string, value: string, autoClear = false) => { void (async () => { @@ -225,7 +247,11 @@ const CardRevealSection = ({ copy('Name on card', values.nameOnCard)} diff --git a/components/Card/NewCardDetails/RegisterSpendAction.tsx b/components/Card/NewCardDetails/RegisterSpendAction.tsx index 793b63c0..3f2f4914 100644 --- a/components/Card/NewCardDetails/RegisterSpendAction.tsx +++ b/components/Card/NewCardDetails/RegisterSpendAction.tsx @@ -1,9 +1,8 @@ -import { ReactNode, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import Toast from 'react-native-toast-message'; -import { Check, Clock, ShieldCheck } from 'lucide-react-native'; +import { Check, Clock, Eye, ShieldCheck } from 'lucide-react-native'; -import SlotTrigger from '@/components/SlotTrigger'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -22,11 +21,20 @@ import { onChainToUsd, usdToOnChain, } from '@/constants/cardSpendModule'; -import { useCardSpendRegistration } from '@/hooks/useCardSpendRegistration'; +import { + CardSpendRegistrationSource, + useCardSpendRegistration, +} from '@/hooks/useCardSpendRegistration'; interface RegisterSpendActionProps { - /** The circular action rendered in the card's action row. */ - trigger: ReactNode; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + /** + * What opened the sheet. Reported on the registration funnel, and `card_reveal` also + * decides whether the sheet explains itself: that entry point went looking for the + * card number and got this instead. + */ + source?: CardSpendRegistrationSource; } /** @@ -65,9 +73,20 @@ interface RegisterSpendActionProps { * The button says which of the two is about to happen, because "your limit is now $500" * would be false for the next day on the raise path — and a user who believed it would * find their card declining at the till. + * + * ## Why it is controlled from outside + * + * Two things open this sheet: the card action row's own "Set up"/"Spending" button, and + * a blocked "Show details" tap — the card's numbers are worth nothing while the module + * cannot debit the Safe. So the open state and the single instance live on the pane + * above both, which is also what keeps the sheet reachable when the action row hides its + * button (a frozen card) and a blocked reveal still needs somewhere to send the user. */ -const RegisterSpendAction = ({ trigger }: RegisterSpendActionProps) => { - const [isOpen, setIsOpen] = useState(false); +const RegisterSpendAction = ({ + isOpen, + onOpenChange, + source = 'spending_sheet', +}: RegisterSpendActionProps) => { const [selectedDaily, setSelectedDaily] = useState(null); // Turning spending off declines the card at the till, so the destructive button asks // once before it signs. Local to the sheet, and cleared whenever it closes, so a @@ -83,6 +102,8 @@ const RegisterSpendAction = ({ trigger }: RegisterSpendActionProps) => { isCancellingIncrease, canDisable, isDisabling, + isLoading: isLoadingRegistration, + refetch, limit, pendingIncrease, error, @@ -137,7 +158,7 @@ const RegisterSpendAction = ({ trigger }: RegisterSpendActionProps) => { ); const closeSheet = () => { - setIsOpen(false); + onOpenChange(false); setIsConfirmingDisable(false); // The picker is an edit of on-chain state, so it must not carry a choice across // openings — a stale selection would show a limit the card does not have. @@ -149,7 +170,7 @@ const RegisterSpendAction = ({ trigger }: RegisterSpendActionProps) => { try { // False means the user dismissed the signature prompt — nothing was enabled, so // saying "set up" would be a lie. Leave the sheet open and say nothing. - if (!(await register(daily))) return; + if (!(await register(daily, source))) return; Toast.show({ type: 'success', text1: isRevoked ? 'Card spending re-enabled' : 'Card spending is set up', @@ -222,269 +243,298 @@ const RegisterSpendAction = ({ trigger }: RegisterSpendActionProps) => { : 'Set up card spending'; return ( - <> - {/* SlotTrigger, not DialogTrigger asChild: the trigger is a CircleAction whose own - padding/label styling lives on its root Pressable, and the asChild Slot chain - drops those classes. SlotTrigger clones and merges onPress instead. */} - setIsOpen(true)}>{trigger} - { - if (next) setIsOpen(true); - else closeSheet(); - }} - > - - - {title} - - {isRegistered - ? 'Your card spends straight from savings, inside the limits below. Change them or turn spending off whenever you like.' - : isRevoked - ? 'You turned card spending off, so payments will be declined. Your limits are still saved — turning it back on restores them.' - : 'Your savings are your card balance. Set a daily limit and your card can spend up to it without asking again — nothing moves until you pay with the card.'} - - + { + if (next) onOpenChange(true); + else closeSheet(); + }} + > + + + {title} + + {isRegistered + ? 'Your card spends straight from savings, inside the limits below. Change them or turn spending off whenever you like.' + : isRevoked + ? 'You turned card spending off, so payments will be declined. Your limits are still saved — turning it back on restores them.' + : 'Your savings are your card balance. Set a daily limit and your card can spend up to it without asking again — nothing moves until you pay with the card.'} + + - {/* Scrolls because the registered state stacks a picker, a summary, a pending - change and the off switch — more than a short phone fits. */} - - {isPaused ? ( - - - Card spending is paused on your account right now. Please contact support before - setting up. - - - ) : null} + {/* Scrolls because the registered state stacks a picker, a summary, a pending + change and the off switch — more than a short phone fits. */} + + {/* Why the user is looking at a spending sheet when they asked for their card + number. Only while that is still the reason: once spending is on, the + reveal works and this is just the limits screen. */} + {source === 'card_reveal' && !isRegistered ? ( + + + + Your card number, expiry and security code stay hidden until the card can spend.{' '} + {isRevoked ? 'Turn spending back on' : 'Set a daily limit'} to see them — until then + every payment would be declined anyway. + + + ) : null} - {/* A raise that has not matured yet. Shown before the picker because it - changes what the numbers underneath mean: the limits below are still the - ones in force, and this is what replaces them and when. */} - {pendingIncrease ? ( - - - - - {formatUsd(pendingIncrease.dailyLimitUsd)} a day starts{' '} - {formatActivationTime(pendingIncrease.activatesAt)} - - - Higher limits wait before they take effect, so an increase you did not ask for - can be stopped. Until then your current limit applies. + {isPaused ? ( + + + Card spending is paused on your account right now. Please contact support before + setting up. + + + ) : null} + + {/* A raise that has not matured yet. Shown before the picker because it + changes what the numbers underneath mean: the limits below are still the + ones in force, and this is what replaces them and when. */} + {pendingIncrease ? ( + + + + + {formatUsd(pendingIncrease.dailyLimitUsd)} a day starts{' '} + {formatActivationTime(pendingIncrease.activatesAt)} + + + Higher limits wait before they take effect, so an increase you did not ask for can + be stopped. Until then your current limit applies. + + + + {isCancellingIncrease ? 'Cancelling…' : 'Cancel this change'} - - - {isCancellingIncrease ? 'Cancelling…' : 'Cancel this change'} - - - + - ) : null} + + ) : null} - {/* The picker: first-time setup, or an edit of a live registration. Hidden in - the revoked state, where the saved limits are restored as they were and - the only decision is whether to turn spending back on. */} - {!isRevoked && presets.length > 0 ? ( - - Daily limit - - {presets.map(dollars => { - const isSelected = dollars === daily; - return ( - setSelectedDaily(dollars)} - style={[styles.preset, isSelected ? styles.presetSelected : null]} - className="transition-all active:scale-95" + {/* The picker: first-time setup, or an edit of a live registration. Hidden in + the revoked state, where the saved limits are restored as they were and + the only decision is whether to turn spending back on. */} + {!isRevoked && presets.length > 0 ? ( + + Daily limit + + {presets.map(dollars => { + const isSelected = dollars === daily; + return ( + setSelectedDaily(dollars)} + style={[styles.preset, isSelected ? styles.presetSelected : null]} + className="transition-all active:scale-95" + > + - - {formatUsd(usdToOnChain(dollars))} - - - ); - })} - - {isChangingLimit ? ( - - {isRaisingLimit - ? `Higher limits take effect after ${formatDelayDuration( - registration?.limitRaiseDelaySeconds ?? 0, - )}. Your current limit applies until then.` - : pendingIncrease - ? // The contract drops a pending raise on any decrease, so the - // user is agreeing to two things with one button. - 'Lowering takes effect straight away, and drops the increase waiting above.' - : 'Lower limits take effect straight away.'} - - ) : null} + {formatUsd(usdToOnChain(dollars))} + + + ); + })} - ) : null} - - - {/* In the revoked state the picker is hidden, so these are the saved - on-chain caps that turning spending back on restores — not a choice. */} - {daily !== null && ( - - )} - {monthly !== null && ( - - )} - {isRegistered && limit ? ( - - ) : null} - {registration ? ( - + {isChangingLimit ? ( + + {isRaisingLimit + ? `Higher limits take effect after ${formatDelayDuration( + registration?.limitRaiseDelaySeconds ?? 0, + )}. Your current limit applies until then.` + : pendingIncrease + ? // The contract drops a pending raise on any decrease, so the + // user is agreeing to two things with one button. + 'Lowering takes effect straight away, and drops the increase waiting above.' + : 'Lower limits take effect straight away.'} + ) : null} + ) : null} - {/* Stated up front, not buried: this grant lets funds leave the Safe without a - further signature. The caps and the off-switch are what make that - acceptable, so they are named in the same breath. */} - {!isRegistered ? ( - - - - Your card can take stablecoins and savings from your Safe up to these limits - without asking again. Nothing else can — payments only ever go to Solid's - settlement account. Turn it off any time and it stops immediately. - - + + {/* In the revoked state the picker is hidden, so these are the saved + on-chain caps that turning spending back on restores — not a choice. */} + {daily !== null && ( + + )} + {monthly !== null && ( + + )} + {isRegistered && limit ? ( + + ) : null} + {registration ? ( + ) : null} + - {registration && presets.length === 0 && !isRegistered && !isRevoked ? ( - - Card spending limits are not open on your account yet. Please try again later. + {/* Stated up front, not buried: this grant lets funds leave the Safe without a + further signature. The caps and the off-switch are what make that + acceptable, so they are named in the same breath. */} + {!isRegistered ? ( + + + + Your card can take stablecoins and savings from your Safe up to these limits without + asking again. Nothing else can — payments only ever go to Solid's settlement + account. Turn it off any time and it stops immediately. - ) : null} + + ) : null} - {error ? {error} : null} + {registration && presets.length === 0 && !isRegistered && !isRevoked ? ( + + Card spending limits are not open on your account yet. Please try again later. + + ) : null} - {isRegistered ? ( - <> - {isAlreadyRequested ? ( - - That change is already requested — it takes effect on{' '} - {formatActivationTime(pendingIncrease!.activatesAt)}. - - ) : isChangingLimit ? ( + {/* Everything above is one on-chain read, and a blocked reveal is now sent + here — so neither waiting for that read nor failing it may leave the user + staring at a disabled button with nothing to act on. */} + {isLoadingRegistration ? ( + + + Checking your card spending… + + ) : null} + + {!registration && !isLoadingRegistration ? ( + + + Could not read your card spending settings. + + void refetch()}> + Try again + + + ) : null} + + {error ? {error} : null} + + {isRegistered ? ( + <> + {isAlreadyRequested ? ( + + That change is already requested — it takes effect on{' '} + {formatActivationTime(pendingIncrease!.activatesAt)}. + + ) : isChangingLimit ? ( + + ) : ( + + + Spending is on + + )} + {/* Only when the module is actually live. In the revoked state it is already + off and the action above is "turn it back on", so an off switch there + would be a button that cannot do anything. */} + {canDisable ? ( + <> + {isConfirmingDisable ? ( + + Your card will decline every payment until you turn spending back on. Your + daily limit stays saved. + + ) : null} - ) : ( - - - Spending is on - - )} - {/* Only when the module is actually live. In the revoked state it is already - off and the action above is "turn it back on", so an off switch there - would be a button that cannot do anything. */} - {canDisable ? ( - <> - {isConfirmingDisable ? ( - - Your card will decline every payment until you turn spending back on. Your - daily limit stays saved. - - ) : null} - - {isConfirmingDisable ? ( - setIsConfirmingDisable(false)} - > - Keep it on - - ) : null} - - One signature removes the module from your Safe. Nothing can be taken from - your savings by the card after that. - - - ) : null} - - ) : ( - - )} + + ) : null} + + ) : ( + + )} - {!isRegistered && !isRevoked ? ( - - One signature enables the module and saves your limits. - - ) : null} - - - - + {!isRegistered && !isRevoked ? ( + + One signature enables the module and saves your limits. + + ) : null} + + + ); }; diff --git a/hooks/useCardSpendRegistration.ts b/hooks/useCardSpendRegistration.ts index 8f1e5626..5e55cd29 100644 --- a/hooks/useCardSpendRegistration.ts +++ b/hooks/useCardSpendRegistration.ts @@ -37,8 +37,16 @@ const SENTINEL_MODULES = '0x0000000000000000000000000000000000000001' as Address /** Enough to cover any real Safe's module list in one read. */ const MODULE_PAGE_SIZE = 50n; -/** Where a registration or limit change was started from, for the funnel. */ -export type CardSpendRegistrationSource = 'spending_sheet' | 'card_activation'; +/** + * Where a registration or limit change was started from, for the funnel. + * + * `card_reveal` is the gate on the card-details reveal: a card whose Safe cannot be + * debited declines every payment, so "Show details" opens the spending sheet instead of + * handing over the PAN. Worth telling apart from `spending_sheet` — someone who came + * looking for their card number is being asked a question they did not go there to + * answer, and how many of them finish it is the thing to watch. + */ +export type CardSpendRegistrationSource = 'spending_sheet' | 'card_activation' | 'card_reveal'; /** The Safe's live limit state, with every matured transition already applied. */ export interface CardSpendLimit { diff --git a/lib/utils/__tests__/cardRevealAccess.test.ts b/lib/utils/__tests__/cardRevealAccess.test.ts new file mode 100644 index 00000000..0b2617f3 --- /dev/null +++ b/lib/utils/__tests__/cardRevealAccess.test.ts @@ -0,0 +1,34 @@ +import { CardProvider } from '@/lib/types'; +import { canRevealCardDetails } from '@/lib/utils/cardHelpers'; + +/** + * The gate on the card-details reveal. A Wirex card spends by our backend debiting the + * user's Safe, so one with the `SolidCashModule` off declines every payment — and the + * numbers of a card that cannot pay read exactly like the numbers of one that can. + * + * The cases that matter are the ones where the answer is not yet known: a chain read + * still in flight, or one that failed, arrives here as `canCardSpend: false`, and must + * block rather than fall open. + */ +describe('canRevealCardDetails', () => { + it('reveals a Wirex card once the module can spend from the Safe', () => { + expect(canRevealCardDetails({ provider: CardProvider.WIREX, canCardSpend: true })).toBe(true); + }); + + it('blocks a Wirex card whose Safe cannot be debited', () => { + // Covers both halves of the module's verdict: never registered, and registered + // with the module since disabled. Either way the card declines. + expect(canRevealCardDetails({ provider: CardProvider.WIREX, canCardSpend: false })).toBe(false); + }); + + it('always reveals a Rain card, which is prefunded and has no module', () => { + expect(canRevealCardDetails({ provider: CardProvider.RAIN, canCardSpend: false })).toBe(true); + }); + + it('reveals while the issuer is unresolved, matching canDepositToCard', () => { + // A card with no SolidCashModule behind it cannot be blocked on one, and the + // reveal request itself needs the issuer to know which flow to run. + expect(canRevealCardDetails({ provider: null, canCardSpend: false })).toBe(true); + expect(canRevealCardDetails({ provider: undefined, canCardSpend: false })).toBe(true); + }); +}); diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index 56579376..1aab1645 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -139,6 +139,40 @@ export const canDepositToCard = (provider: CardProvider | null | undefined): boo export const cardHoldsBalance = (provider: CardProvider | null | undefined): boolean => canDepositToCard(provider); +/** + * Whether the cardholder may be shown their card number, expiry and security code. + * + * Only ever false on a Wirex card, and for one reason: those cards hold no balance. + * Wirex pays the merchant and our backend debits the user's Safe afterwards through + * `SolidCashModule`, so with the module disabled — or the Safe never registered — there + * is nothing to debit and every payment on those numbers is declined. Handing them over + * is handing over a card that looks like it works, which is the same mistake as flipping + * the card onto placeholder digits after a failed reveal. + * + * So the reveal is a spending decision, and `canCardSpend` is the module's own answer + * (`isRegistered` on `useCardSpendRegistration` — module enabled *and* Safe registered, + * both halves). Anything short of a positive reading blocks: a chain read still in + * flight or one that failed is not permission, it is not knowing. + * + * A Rain card is unaffected. It is prefunded and has no module to enable, so there is no + * such thing as a Rain card whose details are worth less than the card itself. An + * unresolved issuer is treated as Rain here, matching {@link canDepositToCard} — a card + * with no `SolidCashModule` behind it cannot be blocked on one. + * + * Note this is not about a card that merely *cannot spend right now*: a freeze, or a + * guardian pause, is temporary and outside the cardholder's hands, and the numbers are + * still theirs to read. This is about the permission the app asked them for and has not + * got. + */ +export const canRevealCardDetails = ({ + provider, + canCardSpend, +}: { + provider: CardProvider | null | undefined; + /** The module's live verdict: enabled on the Safe and the Safe registered. */ + canCardSpend: boolean; +}): boolean => provider !== CardProvider.WIREX || canCardSpend; + /** * Get initials from merchant/person name for avatar display */