Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
24b67f9
fix: auto-format expiration date input in AddPaymentCard form
samranahm Jun 20, 2025
9b79287
use prevValue.length less than 2 to add slash
samranahm Jun 20, 2025
85a24c9
remove didAutoCorrectFirstDigit check
samranahm Jun 21, 2025
d749d92
use first char 1 from value for month above 12 bellow 19
samranahm Jun 24, 2025
45f6951
Merge branch 'Expensify:main' into 64608/expiration-date-input-format
samranahm Jun 27, 2025
c00b03e
fix code format with prettier
samranahm Jun 27, 2025
30b2d76
Update translation in all locales
samranahm Jun 27, 2025
c74ecb1
fix: lint error
samranahm Jun 27, 2025
3c6d33f
fix: prevent entering 00 as month by rejecting second 0
samranahm Jun 28, 2025
5a3158c
fix: handle edge case where user pastes 001 or similar value, replace…
samranahm Jun 28, 2025
11e69d2
fix: changed files ESLint
samranahm Jun 28, 2025
8c250f2
update expiration date validation
samranahm Jun 28, 2025
02fac7e
Fix expiration value formatting for 3-digit pasted input
samranahm Jun 30, 2025
c43d363
Add testID to Expiration date InputWrapper
samranahm Jun 30, 2025
a79f68d
add unit test for expiration date formatting
samranahm Jul 1, 2025
d2ec4e5
Fix typo in variable name previousValueRef
samranahm Jul 14, 2025
0b30d95
Merge branch 'Expensify:main' into 64608/expiration-date-input-format
samranahm Jul 14, 2025
8c8f4bb
fix code format and typo in variable name
samranahm Jul 14, 2025
fa33e0e
add comment to explain expiration date formatting
samranahm Jul 14, 2025
01591fa
fix prettier
samranahm Jul 14, 2025
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
54 changes: 51 additions & 3 deletions src/components/AddPaymentCard/PaymentCardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnimatedTextInputRef>(null);
const [expirationDate, setExpirationDate] = useState(data?.expirationDate);

const previousValueRef = useRef<string>('');

// 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('');

Expand All @@ -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);
}

Expand Down Expand Up @@ -251,14 +296,17 @@ function PaymentCardForm({
<View style={[styles.mr2, styles.flex1]}>
<InputWrapper
defaultValue={data?.expirationDate}
value={expirationDate}
onChangeText={onChangeExpirationDate}
InputComponent={TextInput}
inputID={INPUT_IDS.EXPIRATION_DATE}
label={translate(label.defaults.expiration)}
testID={label.defaults.expiration}
aria-label={translate(label.defaults.expiration)}
role={CONST.ROLE.PRESENTATION}
placeholder={translate(label.defaults.expirationDate)}
inputMode={CONST.INPUT_MODE.NUMERIC}
maxLength={4}
maxLength={5}
/>
</View>
<View style={styles.flex1}>
Expand Down
2 changes: 1 addition & 1 deletion src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1794,7 +1794,7 @@ const translations = {
nameOnCard: 'カード名義人',
paymentCardNumber: 'カード番号',
expiration: '有効期限',
expirationDate: 'MMYY',
expirationDate: 'MM/YY',
cvv: 'CVV',
billingAddress: '請求先住所',
growlMessageOnSave: 'お支払いカードが正常に追加されました',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,7 @@ const translations = {
nameOnCard: '卡上的姓名',
paymentCardNumber: '卡号',
expiration: '到期日期',
expirationDate: 'MMYY',
expirationDate: 'MM/YY',
cvv: 'CVV',
billingAddress: '账单地址',
growlMessageOnSave: '您的支付卡已成功添加',
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/PaymentCardFormTest.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ComposeProviders components={[OnyxProvider, LocaleContextProvider, CurrentReportIDContextProvider]}>
<PortalProvider>
<NavigationContainer>
<Stack.Navigator initialRouteName={initialRouteName}>
<Stack.Screen
name={SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD}
component={AddPaymentCard}
/>
</Stack.Navigator>
</NavigationContainer>
</PortalProvider>
</ComposeProviders>,
);
};

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');
});
});
});
Loading