diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index 382376096e083..83a4bbef6a9e7 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -133,13 +133,55 @@ function PaymentCardForm({ currencySelectorRoute, }: PaymentCardFormProps) { const styles = useThemeStyles(); - const [data, metadata] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM); + const [data, metadata] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM, {canBeMissing: true}); const {translate} = useLocalize(); const route = useRoute(); const label = CARD_LABELS[isDebitCard ? CARD_TYPES.DEBIT_CARD : CARD_TYPES.PAYMENT_CARD]; const cardNumberRef = useRef(null); + const [expirationDate, setExpirationDate] = useState(data?.expirationDate); + + const previousValueRef = useRef(''); + + // Formats user input into a valid expiration date (MM/YY) and automatically adds slash after the month. + // Ensures the month is always between 01 and 12 by correcting invalid value to match the proper format. + const onChangeExpirationDate = useCallback((newValue: string) => { + if (typeof newValue !== 'string') { + return; + } + + let value = newValue.replace(CONST.REGEX.NON_NUMERIC, ''); + + if (value.length === 1) { + const firstDigit = value.charAt(0); + if (parseInt(firstDigit, 10) > 1) { + value = `0${firstDigit}`; + } + } + + if (value.length >= 2) { + const month = parseInt(value.slice(0, 2), 10); + if (value.startsWith('00')) { + value = '0'; + } + if (month > 12) { + value = `0${value.charAt(0)}${value.charAt(1)}${value.charAt(2)}`; + } + } + + const prevValue = previousValueRef.current?.replace(CONST.REGEX.NON_NUMERIC, '') ?? ''; + let formattedValue = value; + + if (value.length === 2 && prevValue.length < 2) { + formattedValue = `${value}/`; + } else if (value.length > 2) { + formattedValue = `${value.slice(0, 2)}/${value.slice(2, 4)}`; + } + + previousValueRef.current = formattedValue; + setExpirationDate(formattedValue); + }, []); const [cardNumber, setCardNumber] = useState(''); @@ -154,7 +196,10 @@ function PaymentCardForm({ errors.cardNumber = translate(label.error.cardNumber); } - if (values.expirationDate && !isValidExpirationDate(values.expirationDate)) { + // When user pastes 5 digit value without slash, trim it to the first 4 digits before validation. + const normalizedExpirationDate = values.expirationDate?.length === 5 && !values.expirationDate.includes('/') ? values.expirationDate.slice(0, 4) : values.expirationDate; + + if (normalizedExpirationDate && !isValidExpirationDate(normalizedExpirationDate)) { errors.expirationDate = translate(label.error.expirationDate); } @@ -251,14 +296,17 @@ function PaymentCardForm({ diff --git a/src/languages/de.ts b/src/languages/de.ts index ddec754f14f55..fd6a049a39a57 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1803,7 +1803,7 @@ const translations = { nameOnCard: 'Name auf der Karte', paymentCardNumber: 'Kartennummer', expiration: 'Ablaufdatum', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Rechnungsadresse', growlMessageOnSave: 'Ihre Zahlungskarte wurde erfolgreich hinzugefügt', diff --git a/src/languages/en.ts b/src/languages/en.ts index f0c4637819be6..f9d0d385d6653 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1780,7 +1780,7 @@ const translations = { nameOnCard: 'Name on card', paymentCardNumber: 'Card number', expiration: 'Expiration date', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Billing address', growlMessageOnSave: 'Your payment card was successfully added', diff --git a/src/languages/es.ts b/src/languages/es.ts index c5ed098402fe7..2dc4daa78e010 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1780,7 +1780,7 @@ const translations = { nameOnCard: 'Nombre en la tarjeta', paymentCardNumber: 'Número de la tarjeta', expiration: 'Fecha de vencimiento', - expirationDate: 'MMAA', + expirationDate: 'MM/AA', cvv: 'CVV', billingAddress: 'Dirección de envio', growlMessageOnSave: 'Tu tarjeta de pago se añadió correctamente', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 12814d905b03f..7a6309ecd327c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1805,7 +1805,7 @@ const translations = { nameOnCard: 'Nom sur la carte', paymentCardNumber: 'Numéro de carte', expiration: "Date d'expiration", - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Adresse de facturation', growlMessageOnSave: 'Votre carte de paiement a été ajoutée avec succès', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8e213c0b30f2d..4402998e53dfb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1796,7 +1796,7 @@ const translations = { nameOnCard: 'Nome sulla carta', paymentCardNumber: 'Numero di carta', expiration: 'Data di scadenza', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Indirizzo di fatturazione', growlMessageOnSave: 'La tua carta di pagamento è stata aggiunta con successo', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 50436da80590a..96c0dcb91cfa2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1794,7 +1794,7 @@ const translations = { nameOnCard: 'カード名義人', paymentCardNumber: 'カード番号', expiration: '有効期限', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: '請求先住所', growlMessageOnSave: 'お支払いカードが正常に追加されました', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6b3aa7fc43dec..98a95390b689e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1796,7 +1796,7 @@ const translations = { nameOnCard: 'Naam op kaart', paymentCardNumber: 'Kaartnummer', expiration: 'Vervaldatum', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Factuuradres', growlMessageOnSave: 'Uw betaalkaart is succesvol toegevoegd', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e48a957722b41..d10f0a5f9ad28 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1792,7 +1792,7 @@ const translations = { nameOnCard: 'Imię na karcie', paymentCardNumber: 'Numer karty', expiration: 'Data wygaśnięcia', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Adres rozliczeniowy', growlMessageOnSave: 'Twoja karta płatnicza została pomyślnie dodana', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 158187a7911a7..96d88342e4cad 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1794,7 +1794,7 @@ const translations = { nameOnCard: 'Nome no cartão', paymentCardNumber: 'Número do cartão', expiration: 'Data de validade', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: 'Endereço de cobrança', growlMessageOnSave: 'Seu cartão de pagamento foi adicionado com sucesso', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8e3ece8b49d48..24cdd88c6d4b1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1777,7 +1777,7 @@ const translations = { nameOnCard: '卡上的姓名', paymentCardNumber: '卡号', expiration: '到期日期', - expirationDate: 'MMYY', + expirationDate: 'MM/YY', cvv: 'CVV', billingAddress: '账单地址', growlMessageOnSave: '您的支付卡已成功添加', diff --git a/tests/unit/PaymentCardFormTest.tsx b/tests/unit/PaymentCardFormTest.tsx new file mode 100644 index 0000000000000..0f3dad2ada303 --- /dev/null +++ b/tests/unit/PaymentCardFormTest.tsx @@ -0,0 +1,122 @@ +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import {createStackNavigator} from '@react-navigation/stack'; +import {fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxProvider from '@components/OnyxProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import AddPaymentCard from '@pages/settings/Subscription/PaymentCard'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +jest.mock('@react-native-community/geolocation', () => ({ + setRNConfiguration: jest.fn(), +})); + +jest.mock('@libs/ReportUtils', () => ({ + UnreadIndicatorUpdaterHelper: jest.fn(), + getReportIDFromLink: jest.fn(() => ''), + parseReportRouteParams: jest.fn(() => ({reportID: ''})), +})); + +jest.mock('@pages/settings/Subscription/PaymentCard', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return jest.requireActual('@pages/settings/Subscription/PaymentCard/index.tsx'); +}); + +beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); +}); + +afterAll(() => { + jest.clearAllMocks(); +}); + +describe('Subscription/AddPaymentCard', () => { + const Stack = createStackNavigator(); + + const renderAddPaymentCardPage = (initialRouteName: typeof SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD) => { + return render( + + + + + + + + + , + ); + }; + + describe('AddPaymentCardPage Expiration Date Formatting', () => { + const runFormatTest = async (input: string, formattedAs: string) => { + renderAddPaymentCardPage(SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD); + const expirationDateField = await screen.findByTestId('addPaymentCardPage.expiration'); + fireEvent.changeText(expirationDateField, input); + expect(expirationDateField.props.value).toBe(formattedAs); + }; + + it('formats "0" as "0"', async () => { + await runFormatTest('0', '0'); + }); + + it('formats "2" as "02/"', async () => { + await runFormatTest('2', '02/'); + }); + + it('formats "11" as "11/"', async () => { + await runFormatTest('11', '11/'); + }); + + it('formats "13" as "01/3"', async () => { + await runFormatTest('13', '01/3'); + }); + + it('formats "20" as "02/0"', async () => { + await runFormatTest('20', '02/0'); + }); + + it('formats "45" as "04/5"', async () => { + await runFormatTest('45', '04/5'); + }); + + it('formats "98" as "09/8"', async () => { + await runFormatTest('98', '09/8'); + }); + + it('formats "123" as "12/3"', async () => { + await runFormatTest('123', '12/3'); + }); + + it('formats "567" as "05/67"', async () => { + await runFormatTest('567', '05/67'); + }); + + it('formats "00" as "0"', async () => { + await runFormatTest('00', '0'); + }); + + it('formats "11111" as "11/11"', async () => { + await runFormatTest('11111', '11/11'); + }); + + it('formats "99/99" as "09/99"', async () => { + await runFormatTest('99/99', '09/99'); + }); + + it('formats "0825" as "08/25"', async () => { + await runFormatTest('0825', '08/25'); + }); + + it('formats "12255" as "12/25"', async () => { + await runFormatTest('12255', '12/25'); + }); + }); +});