diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ec834abc4657e..3cf4f6dbb44af 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -725,6 +725,7 @@ const CONST = { BETAS: { ALL: 'all', ASAP_SUBMIT: 'asapSubmit', + CSV_CARD_IMPORT: 'csvCardImport', DEFAULT_ROOMS: 'defaultRooms', PREVENT_SPOTNANA_TRAVEL: 'preventSpotnanaTravel', REPORT_FIELDS_FEATURE: 'reportFieldsFeature', diff --git a/src/languages/de.ts b/src/languages/de.ts index 62560e95799d4..f58ad87626dbc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2188,6 +2188,9 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} verliert den Zugriff auf dieses Geschäftskonto. Laufende Zahlungen werden weiterhin ausgeführt.`, reachOutForHelp: 'Dieses Konto wird mit der Expensify Card verwendet. Wenden Sie sich an den Concierge, wenn Sie die Freigabe aufheben möchten.', unshareErrorModalTitle: 'Bankkonto kann nicht freigegeben werden', + deleteCard: 'Karte löschen', + deleteCardConfirmation: + 'Alle nicht eingereichten Kartentransaktionen, einschließlich der auf offenen Berichten, werden entfernt. Möchten Sie diese Karte wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.', }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/en.ts b/src/languages/en.ts index 26bf02f5a72d9..6003d801c1607 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2177,6 +2177,9 @@ const translations = { setDefaultSuccess: 'Default payment method set!', deleteAccount: 'Delete account', deleteConfirmation: 'Are you sure you want to delete this account?', + deleteCard: 'Delete card', + deleteCardConfirmation: + 'All unsubmitted card transactions, including those on open reports, will be removed. Are you sure you want to delete this card? You cannot undo this action.', error: { notOwnerOfBankAccount: 'An error occurred while setting this bank account as your default payment method', invalidBankAccount: 'This bank account is temporarily suspended', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1b70236bc8cf6..d034fbd4c9316 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1906,6 +1906,9 @@ const translations: TranslationDeepObject = { setDefaultSuccess: 'Método de pago configurado', deleteAccount: 'Eliminar cuenta', deleteConfirmation: '¿Estás seguro de que quieres eliminar esta cuenta?', + deleteCard: 'Eliminar tarjeta', + deleteCardConfirmation: + 'Todas las transacciones no enviadas, incluidas las de informes abiertos, serán eliminadas. ¿Estás seguro de que quieres eliminar esta tarjeta? Esta acción no se puede deshacer.', error: { notOwnerOfBankAccount: 'Se ha producido un error al establecer esta cuenta bancaria como método de pago predeterminado', invalidBankAccount: 'Esta cuenta bancaria está temporalmente suspendida', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f449e62639134..3664c895f6419 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2194,6 +2194,9 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perdra l’accès à ce compte bancaire professionnel. Les paiements en cours seront honorés.`, reachOutForHelp: 'Ce compte est utilisé avec la carte Expensify. Contactez le service de conciergerie si vous souhaitez le retirer du partage.', unshareErrorModalTitle: 'Impossible de retirer le partage du compte bancaire', + deleteCard: 'Supprimer la carte', + deleteCardConfirmation: + 'Toutes les transactions de carte non soumises, y compris celles figurant dans les rapports ouverts, seront supprimées. Êtes-vous sûr de vouloir supprimer cette carte ? Cette action est irréversible.', }, cardPage: { expensifyCard: 'Carte Expensify', @@ -7042,7 +7045,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de retrait', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Catégorie', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Commerçant', - [CONST.SEARCH.GROUP_BY.TAG]: 'Étiquette', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', [CONST.SEARCH.GROUP_BY.MONTH]: 'Mois', [CONST.SEARCH.GROUP_BY.WEEK]: 'Semaine', [CONST.SEARCH.GROUP_BY.YEAR]: 'Année', diff --git a/src/languages/it.ts b/src/languages/it.ts index 49d78de81efbd..47d1d28fa8556 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2185,6 +2185,9 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perderà l'accesso a questo conto bancario aziendale. Completeremo comunque tutti i pagamenti in corso.`, reachOutForHelp: 'È in uso con la carta Expensify. Contatta il Concierge se devi revocare la condivisione.', unshareErrorModalTitle: 'Impossibile revocare la condivisione del conto bancario', + deleteCard: 'Elimina carta', + deleteCardConfirmation: + 'Tutte le transazioni con carta non inviate, comprese quelle nei report aperti, verranno rimosse. Sei sicuro di voler eliminare questa carta? Non puoi annullare questa azione.', }, cardPage: { expensifyCard: 'Carta Expensify', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4fdad6e86b5f6..9e367b56d38aa 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2179,6 +2179,8 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} はこのビジネス銀行口座にアクセスできなくなります。処理中のお支払いは引き続き完了します。`, reachOutForHelp: 'この口座は Expensify カードで使用されています。共有を解除する必要がある場合は、コンシェルジュまでお問い合わせください。', unshareErrorModalTitle: '銀行口座の共有を解除できません', + deleteCard: 'カードを削除', + deleteCardConfirmation: '未提出のカード取引(未精算レポート上の取引も含む)はすべて削除されます。このカードを本当に削除してもよろしいですか?この操作は元に戻せません。', }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5399ad49a4c69..85eb9a56f81cd 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2184,6 +2184,9 @@ const translations: TranslationDeepObject = { `${admin} verliest de toegang tot deze zakelijke bankrekening. We zullen alle betalingen die in behandeling zijn nog steeds voltooien.`, reachOutForHelp: 'Deze wordt gebruikt met de Expensify Card. Neem contact op met Concierge als u de deling ongedaan wilt maken.', unshareErrorModalTitle: 'Deling bankrekening kan niet ongedaan worden gemaakt', + deleteCard: 'Kaart verwijderen', + deleteCardConfirmation: + 'Alle niet-ingediende kaarttransacties, inclusief die in openstaande rapporten, worden verwijderd. Weet je zeker dat je deze kaart wilt verwijderen? Je kunt deze actie niet ongedaan maken.', }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b1a644a511113..d5bfcaf99595e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2181,6 +2181,9 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} utraci dostęp do tego firmowego konta bankowego. Nadal będziemy realizować wszystkie płatności w toku.`, reachOutForHelp: 'Jest ono używane z kartą Expensify. Skontaktuj się z Concierge, jeśli chcesz je anulować.', unshareErrorModalTitle: 'Nie można anulować udostępniania konta bankowego', + deleteCard: 'Usuń kartę', + deleteCardConfirmation: + 'Wszystkie niewysłane transakcje kartą, w tym znajdujące się w otwartych raportach, zostaną usunięte. Czy na pewno chcesz usunąć tę kartę? Nie można cofnąć tej czynności.', }, cardPage: { expensifyCard: 'Karta Expensify', @@ -6987,7 +6990,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i groupBy: { [CONST.SEARCH.GROUP_BY.FROM]: 'Od', [CONST.SEARCH.GROUP_BY.CARD]: 'Karta', - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID wypłaty', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Identyfikator wypłaty', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategoria', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Sprzedawca', [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7afaa133225a7..04f4c4e09f46f 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2179,6 +2179,9 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} perderá o acesso a esta conta bancária comercial. Ainda concluiremos quaisquer pagamentos em andamento.`, reachOutForHelp: 'Está sendo usada com o Cartão Expensify. Entre em contato com o Concierge se precisar descompartilhar.', unshareErrorModalTitle: 'Não foi possível descompartilhar a conta bancária', + deleteCard: 'Excluir cartão', + deleteCardConfirmation: + 'Todas as transações de cartão não enviadas, incluindo aquelas em relatórios em aberto, serão removidas. Tem certeza de que deseja excluir este cartão? Esta ação não pode ser desfeita.', }, cardPage: { expensifyCard: 'Cartão Expensify', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a1411321c4714..8fab176896634 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2152,6 +2152,8 @@ const translations: TranslationDeepObject = { unshareBankAccountWarning: ({admin}: {admin?: string | null}) => `${admin} 将失去对此企业银行账户的访问权限。我们仍将完成所有正在进行的付款。`, reachOutForHelp: '此账户正在与 Expensify 卡一起使用。如果您需要取消共享,请联系礼宾部。', unshareErrorModalTitle: '无法取消共享银行账户', + deleteCard: '删除卡片', + deleteCardConfirmation: '所有未提交的银行卡交易(包括开放报表中的交易)都将被移除。确定要删除此银行卡吗?此操作无法撤销。', }, cardPage: { expensifyCard: 'Expensify Card', diff --git a/src/libs/API/parameters/DeletePersonalCardParams.ts b/src/libs/API/parameters/DeletePersonalCardParams.ts new file mode 100644 index 0000000000000..c04fee2f0301d --- /dev/null +++ b/src/libs/API/parameters/DeletePersonalCardParams.ts @@ -0,0 +1,5 @@ +type DeletePersonalCardParams = { + cardID: number; +}; + +export default DeletePersonalCardParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f84868b550bdd..90d654ee1a4e0 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -35,6 +35,7 @@ export type {default as SyncPolicyToQuickbooksDesktopParams} from './SyncPolicyT export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; +export type {default as DeletePersonalCardParams} from './DeletePersonalCardParams'; export type {default as TogglePolicyUberAutoInvitePageParams} from './TogglePolicyUberAutoInvitePageParams'; export type {default as DismissReferralBannerParams} from './DismissReferralBannerParams'; export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 08936a2897419..fa66be0f37789 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -49,6 +49,7 @@ const WRITE_COMMANDS = { UPDATE_EXPENSIFY_CARD_TITLE: 'UpdateExpensifyCardTitle', UPDATE_EXPENSIFY_CARD_LIMIT_TYPE: 'UpdateExpensifyCardLimitType', CARD_DEACTIVATE: 'Card_Deactivate', + DELETE_PERSONAL_CARD: 'DeleteCard', CHRONOS_REMOVE_OOO_EVENT: 'Chronos_RemoveOOOEvent', MAKE_DEFAULT_PAYMENT_METHOD: 'MakeDefaultPaymentMethod', TOGGLE_WORKSPACE_UBER_AUTO_INVITE: 'ToggleWorkspaceUberAutoInvite', @@ -572,6 +573,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_TITLE]: Parameters.UpdateExpensifyCardTitleParams; [WRITE_COMMANDS.UPDATE_EXPENSIFY_CARD_LIMIT_TYPE]: Parameters.UpdateExpensifyCardLimitTypeParams; [WRITE_COMMANDS.CARD_DEACTIVATE]: Parameters.CardDeactivateParams; + [WRITE_COMMANDS.DELETE_PERSONAL_CARD]: Parameters.DeletePersonalCardParams; [WRITE_COMMANDS.MAKE_DEFAULT_PAYMENT_METHOD]: Parameters.MakeDefaultPaymentMethodParams; [WRITE_COMMANDS.TOGGLE_WORKSPACE_UBER_AUTO_INVITE]: Parameters.TogglePolicyUberAutoInvitePageParams; [WRITE_COMMANDS.SET_WORKSPACE_UBER_CENTRAL_BILL]: Parameters.ChangePolicyUberBillingAccountPageParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a4776ba6e3ed1..12f87508c6971 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2002,6 +2002,27 @@ function isOpenReport(report: OnyxEntry): boolean { return report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; } +/** + * Checks if a report is in an open/unsubmitted state where its transactions can be deleted. + * Returns true for: + * - Undefined or empty reportID + * - UNREPORTED_REPORT_ID (transactions not yet on a report) + * - Reports that don't exist in the collection + * - Reports with stateNum === OPEN + * This matches the backend logic in Card::remove which deletes transactions on open reports. + */ +function isReportOpenOrUnsubmitted(reportID: string | undefined, reports: OnyxCollection): boolean { + if (!reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + return true; + } + // Backend deletes transactions on reports with state = STATE_OPEN (0) + return report.stateNum === CONST.REPORT.STATE_NUM.OPEN; +} + /** * Determines if a report requires manual submission based on policy settings and report state */ @@ -13418,6 +13439,7 @@ export { isTrackExpenseReportNew, shouldHideSingleReportField, getReportForHeader, + isReportOpenOrUnsubmitted, }; export type { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 4587f50832772..669e4a51fb8dd 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -1,10 +1,11 @@ import Onyx from 'react-native-onyx'; -import type {OnyxUpdate} from 'react-native-onyx'; +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import type { ActivatePhysicalExpensifyCardParams, CardDeactivateParams, + DeletePersonalCardParams, OpenCardDetailsPageParams, ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, @@ -22,9 +23,10 @@ import type { import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Card, CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/types/onyx'; import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; import type {ConnectionName} from '@src/types/onyx/Policy'; @@ -1022,6 +1024,74 @@ function deactivateCard(workspaceAccountID: number, card?: Card) { API.write(WRITE_COMMANDS.CARD_DEACTIVATE, parameters, {optimisticData, failureData}); } +type DeletePersonalCardData = { + cardID: number; + card?: Card; + allTransactions: OnyxCollection; + allReports: OnyxCollection; +}; + +/** + * Deletes a personal card (CSV-imported card) and its associated transactions. + * The backend will handle deleting transactions on unsubmitted/open reports. + */ +function deletePersonalCard({cardID, card, allTransactions, allReports}: DeletePersonalCardData) { + // Find all transactions associated with this card that are on open/unsubmitted reports + // This matches the backend logic which only deletes transactions on open reports + const transactionsToDelete: Transaction[] = []; + for (const transaction of Object.values(allTransactions ?? {})) { + if (transaction?.cardID === cardID && isReportOpenOrUnsubmitted(transaction.reportID, allReports)) { + transactionsToDelete.push(transaction); + } + } + + // Optimistically remove the card immediately for instant UI feedback + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: null, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + ...card, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ]; + + // Optimistically delete transactions and prepare failure data to restore them + for (const transaction of transactionsToDelete) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: null, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }); + } + + const parameters: DeletePersonalCardParams = { + cardID, + }; + + API.write(WRITE_COMMANDS.DELETE_PERSONAL_CARD, parameters, {optimisticData, failureData}); +} + function startIssueNewCardFlow(policyID: string | undefined) { const parameters: StartIssueNewCardFlowParams = { policyID, @@ -1389,6 +1459,7 @@ export { updateSelectedFeed, updateSelectedExpensifyCardFeed, deactivateCard, + deletePersonalCard, getCardDefaultName, queueExpensifyCardForBilling, clearIssueNewCardFormData, diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index ea15a87e14473..36ca84de32274 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -253,22 +253,23 @@ function PaymentMethodList({ } else { cardDescription = getDescriptionForPolicyDomainCard(card.domainName, policiesForAssignedCards); } - // Personal cards navigate to personal card details page + // Personal cards navigate to personal card details page (except CSV cards which need 3-dot menu for delete) // Company cards use the pressHandler callback (for 3-dot menu behavior) - const cardOnPress = isUserPersonalCard - ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.getRoute(String(card.cardID))) - : (e: GestureResponderEvent | KeyboardEvent | undefined) => - pressHandler({ - event: e, - cardData: card, - icon: { - icon, - iconStyles: [styles.cardIcon], - iconWidth: variables.cardIconWidth, - iconHeight: variables.cardIconHeight, - }, - cardID: card.cardID, - }); + const cardOnPress = + isUserPersonalCard && !isCSVCard + ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.getRoute(String(card.cardID))) + : (e: GestureResponderEvent | KeyboardEvent | undefined) => + pressHandler({ + event: e, + cardData: card, + icon: { + icon, + iconStyles: [styles.cardIcon], + iconWidth: variables.cardIconWidth, + iconHeight: variables.cardIconHeight, + }, + cardID: card.cardID, + }); assignedCardsGrouped.push({ key: card.cardID.toString(), @@ -278,7 +279,7 @@ function PaymentMethodList({ interactive: !isDisabled, disabled: isDisabled, shouldShowRightIcon, - shouldShowThreeDotsMenu: !isUserPersonalCard, + shouldShowThreeDotsMenu: !isUserPersonalCard || isCSVCard, errors: card.errors, canDismissError: false, pendingAction: card.pendingAction, diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index 8f8bfec53246c..9728427f6c5c8 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -15,11 +15,13 @@ import type {PaymentMethodType, Source} from '@components/KYCWall/types'; import {LockedAccountContext} from '@components/LockedAccountModalProvider'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -27,6 +29,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaymentMethodState from '@hooks/usePaymentMethodState'; import type {FormattedSelectedPaymentMethod} from '@hooks/usePaymentMethodState/types'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -38,6 +41,7 @@ import {getDescriptionForPolicyDomainCard, hasEligibleActiveAdminFromWorkspaces} import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import PaymentMethodList from '@pages/settings/Wallet/PaymentMethodList'; import {deletePaymentBankAccount, openPersonalBankAccountSetupView, setPersonalBankAccountContinueKYCOnSuccess} from '@userActions/BankAccounts'; +import {deletePersonalCard} from '@userActions/Card'; import {close as closeModal} from '@userActions/Modal'; import {clearWalletError, clearWalletTermsError, deletePaymentCard, getPaymentMethods, makeDefaultPaymentMethod as makeDefaultPaymentMethodPaymentMethods} from '@userActions/PaymentMethods'; import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; @@ -60,6 +64,8 @@ function WalletPage() { selector: fundListSelector, }); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [isLoadingPaymentMethods = true] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS, {canBeMissing: true}); const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET, {canBeMissing: true}); @@ -81,10 +87,13 @@ function WalletPage() { const {translate} = useLocalize(); const network = useNetwork(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); const {paymentMethod, setPaymentMethod, resetSelectedPaymentMethodData} = usePaymentMethodState(); + const {showConfirmModal} = useConfirmModal(); const [shouldShowLoadingSpinner, setShouldShowLoadingSpinner] = useState(false); const paymentMethodButtonRef = useRef(null); const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false); + const [selectedCard, setSelectedCard] = useState(undefined); const [shouldShowShareButton, setShouldShowShareButton] = useState(false); const [shouldShowUnshareButton, setShouldShowUnshareButton] = useState(false); const kycWallRef = useContext(KYCWallContext); @@ -164,6 +173,7 @@ function WalletPage() { const assignedCardPressed = ({event, cardData, icon, cardID}: CardPressHandlerParams) => { paymentMethodButtonRef.current = event?.currentTarget as HTMLDivElement; + setSelectedCard(cardData); setPaymentMethod({ isSelectedPaymentMethodDefault: false, selectedPaymentMethod: {}, @@ -239,6 +249,29 @@ function WalletPage() { Navigation.navigate(source === CONST.KYC_WALL_SOURCE.ENABLE_WALLET ? ROUTES.SETTINGS_WALLET : ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE); }; + /** + * Show confirmation modal for deleting a personal card and delete it if confirmed + */ + const confirmDeleteCard = useCallback(async () => { + if (!selectedCard?.cardID) { + return; + } + + const result = await showConfirmModal({ + title: translate('walletPage.deleteCard'), + prompt: translate('walletPage.deleteCardConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + danger: true, + }); + + if (result.action === ModalActions.CONFIRM) { + deletePersonalCard({cardID: selectedCard.cardID, card: selectedCard, allTransactions, allReports}); + } + setSelectedCard(undefined); + }, [selectedCard, showConfirmModal, translate, allTransactions, allReports]); + useEffect(() => { // If the user was previously offline, skip debouncing showing the loader if (!network.isOffline) { @@ -408,8 +441,10 @@ function WalletPage() { ], ); - const cardThreeDotsMenuItems = useMemo( - () => [ + const cardThreeDotsMenuItems = useMemo(() => { + const isCSVImport = selectedCard?.bank === CONST.COMPANY_CARDS.BANK_NAME.UPLOAD; + const shouldShowDeleteCardButton = isCSVImport && isBetaEnabled(CONST.BETAS.CSV_CARD_IMPORT); + return [ ...(shouldUseNarrowLayout ? [bottomMountItem] : []), { text: translate('workspace.common.viewTransactions'), @@ -426,9 +461,21 @@ function WalletPage() { ); }, }, - ], - [bottomMountItem, icons.MoneySearch, paymentMethod.methodID, shouldUseNarrowLayout, translate], - ); + ...(shouldShowDeleteCardButton + ? [ + { + text: translate('common.delete'), + icon: icons.Trashcan, + onSelected: () => { + closeModal(() => { + confirmDeleteCard(); + }); + }, + }, + ] + : []), + ]; + }, [bottomMountItem, confirmDeleteCard, isBetaEnabled, icons.MoneySearch, icons.Trashcan, paymentMethod.methodID, selectedCard?.bank, shouldUseNarrowLayout, translate]); if (isLoadingApp) { return ( diff --git a/tests/unit/isReportOpenOrUnsubmittedTest.ts b/tests/unit/isReportOpenOrUnsubmittedTest.ts new file mode 100644 index 0000000000000..1f6c02af41ec1 --- /dev/null +++ b/tests/unit/isReportOpenOrUnsubmittedTest.ts @@ -0,0 +1,94 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +describe('isReportOpenOrUnsubmitted', () => { + const createReport = (reportID: string, stateNum: number, statusNum: number): Report => + ({ + reportID, + stateNum, + statusNum, + }) as Report; + + const createReportsCollection = (reports: Report[]): OnyxCollection => { + const collection: OnyxCollection = {}; + for (const report of reports) { + collection[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; + } + return collection; + }; + + describe('returns true for edge cases', () => { + it('should return true when reportID is undefined', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)]); + + expect(isReportOpenOrUnsubmitted(undefined, reports)).toBe(true); + }); + + it('should return true when reportID is empty string', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)]); + + expect(isReportOpenOrUnsubmitted('', reports)).toBe(true); + }); + + it('should return true when reportID is UNREPORTED_REPORT_ID', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)]); + + expect(isReportOpenOrUnsubmitted(CONST.REPORT.UNREPORTED_REPORT_ID, reports)).toBe(true); + }); + + it('should return true when reports collection is undefined', () => { + expect(isReportOpenOrUnsubmitted('123', undefined)).toBe(true); + }); + + it('should return true when report does not exist in collection', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)]); + + expect(isReportOpenOrUnsubmitted('999', reports)).toBe(true); + }); + }); + + describe('returns true for open reports', () => { + it('should return true when report stateNum is OPEN', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN)]); + + expect(isReportOpenOrUnsubmitted('1', reports)).toBe(true); + }); + }); + + describe('returns false for non-open reports', () => { + it('should return false when report stateNum is SUBMITTED', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED)]); + + expect(isReportOpenOrUnsubmitted('1', reports)).toBe(false); + }); + + it('should return false when report stateNum is APPROVED', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED)]); + + expect(isReportOpenOrUnsubmitted('1', reports)).toBe(false); + }); + + it('should return false when report stateNum is BILLING', () => { + const reports = createReportsCollection([createReport('1', CONST.REPORT.STATE_NUM.BILLING, CONST.REPORT.STATUS_NUM.CLOSED)]); + + expect(isReportOpenOrUnsubmitted('1', reports)).toBe(false); + }); + }); + + describe('handles multiple reports correctly', () => { + it('should check the correct report in a collection with multiple reports', () => { + const reports = createReportsCollection([ + createReport('1', CONST.REPORT.STATE_NUM.OPEN, CONST.REPORT.STATUS_NUM.OPEN), + createReport('2', CONST.REPORT.STATE_NUM.APPROVED, CONST.REPORT.STATUS_NUM.APPROVED), + createReport('3', CONST.REPORT.STATE_NUM.SUBMITTED, CONST.REPORT.STATUS_NUM.SUBMITTED), + ]); + + expect(isReportOpenOrUnsubmitted('1', reports)).toBe(true); + expect(isReportOpenOrUnsubmitted('2', reports)).toBe(false); + expect(isReportOpenOrUnsubmitted('3', reports)).toBe(false); + }); + }); +});