Fakultas Ilmu Komputer UI

Commit d401fdd6 authored by Wulan Mantiri's avatar Wulan Mantiri
Browse files

Merge branch 'PBI-8-payment_inapp_logic' into 'staging'

Add payment webview integration, fix auth logic

See merge request !53
parents c73e2c4a 5ac1834d
Pipeline #77693 passed with stages
in 48 minutes and 6 seconds
......@@ -39,6 +39,7 @@ test:
- yarn install
- yarn test --silent
artifacts:
expire_in: 1 week
paths:
- coverage
......@@ -84,9 +85,10 @@ android:
- yarn install
- export ANDROID_SDK_ROOT=/usr/lib/android-sdk
- cd android
- chmod +x gradlew && ./gradlew assembleRelease
- chmod +x gradlew && ./gradlew clean && ./gradlew assembleRelease
- cd .. && cp android/app/build/outputs/apk/release/app-release.apk $CI_PROJECT_NAME-$CI_COMMIT_REF_NAME.apk
artifacts:
expire_in: 1 week
name: '$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME'
paths:
- $CI_PROJECT_NAME-$CI_COMMIT_REF_NAME.apk
......
......@@ -30,20 +30,18 @@ const NavigationStack: FC = () => {
<Stack.Navigator
initialRouteName={initialRoute}
screenOptions={screenOptions}>
<>
{navigation.map((nav, i) => (
<Stack.Screen
key={`nav${i}`}
name={nav.name}
component={nav.component}
options={{
title: nav.header,
headerShown: Boolean(nav.header),
headerRight: LogoutButton,
}}
/>
))}
</>
{navigation.map((nav, i) => (
<Stack.Screen
key={`nav${i}`}
name={nav.name}
component={nav.component}
options={{
title: nav.header,
headerShown: Boolean(nav.header),
headerRight: LogoutButton,
}}
/>
))}
</Stack.Navigator>
</NavigationContainer>
);
......
import * as ROUTES from 'constants/routes';
import {
publicNavigation,
unpaidClientNavigation,
clientNavigation,
nutritionistNavigation,
} from 'constants/navigation';
......@@ -14,19 +13,20 @@ export const getNavigation = (
) => {
if (isAuthenticated) {
if (user.role === UserRole.CLIENT) {
if (user.transaction_status === TransactionStatus.UNPAID) {
return {
initialRoute: ROUTES.checkout,
navigation: unpaidClientNavigation,
};
let initialRoute = ROUTES.clientProfile;
if ([TransactionStatus.UNPAID, null].includes(user.transaction_status)) {
initialRoute = ROUTES.checkout;
} else if (user.transaction_status === TransactionStatus.PENDING) {
initialRoute = ROUTES.paymentResult;
} else if (!user.is_finished_onboarding) {
initialRoute = ROUTES.extendedQuestionnaire;
}
return {
initialRoute: user.is_finished_onboarding
? ROUTES.clientProfile
: ROUTES.extendedQuestionnaire,
initialRoute,
navigation: clientNavigation,
};
}
if (user.role === UserRole.NUTRITIONIST) {
return {
initialRoute: ROUTES.clientListForNutritionist,
......@@ -34,6 +34,7 @@ export const getNavigation = (
};
}
}
return {
initialRoute: ROUTES.initial,
navigation: publicNavigation,
......
......@@ -10,10 +10,10 @@ import {
} from './programDetail';
const prices = {
oneWeek: '239,900',
oneMonth: '609,900',
threeMonths: '1,659,900',
sixMonths: '3,449,000',
oneWeek: '239.900',
oneMonth: '609.900',
threeMonths: '1.659.900',
sixMonths: '3.449.000',
};
export const dietPrograms = {
......
......@@ -28,6 +28,7 @@ import {
ComingSoonPage,
ClientProfile,
ClientProfileForAdmin,
PaymentWebView,
} from 'scenes';
import { FC } from 'react';
......@@ -88,26 +89,22 @@ export const publicNavigation: NavRoute[] = [
},
];
export const unpaidClientNavigation: NavRoute[] = [
export const clientNavigation: NavRoute[] = [
{
name: ROUTES.checkout,
component: Checkout,
header: 'Checkout',
},
{
name: ROUTES.paymentResult,
component: PaymentResult,
name: ROUTES.payment,
component: PaymentWebView,
header: 'Pembayaran',
},
...navigation,
];
export const clientNavigation: NavRoute[] = [
{
name: ROUTES.clientProfile,
component: ClientProfile,
header: 'Profil Saya',
name: ROUTES.paymentResult,
component: PaymentResult,
},
...navigation,
{
name: ROUTES.extendedQuestionnaire,
component: ExtendedQuestionnaire,
......@@ -142,6 +139,11 @@ export const clientNavigation: NavRoute[] = [
...nav,
name: ROUTES.extendedQuestionnaireById(id),
})),
{
name: ROUTES.clientProfile,
component: ClientProfile,
header: 'Profil Saya',
},
];
export const nutritionistNavigation: NavRoute[] = [
......@@ -176,7 +178,6 @@ export const adminNavigation: NavRoute[] = [
];
export const testNavigation: NavRoute[] = [
...navigation,
...clientNavigation,
...nutritionistNavigation,
{
......@@ -198,14 +199,4 @@ export const testNavigation: NavRoute[] = [
component: NutritionistAdminLogin,
header: 'Login Tim Dietela',
},
{
name: ROUTES.checkout,
component: Checkout,
header: 'Checkout',
},
{
name: ROUTES.paymentResult,
component: PaymentResult,
header: 'Pembayaran',
},
];
......@@ -21,7 +21,7 @@ export const nutritionistAdminLogin = 'nutritionist-admin-login';
const profile = 'profile';
export const clientProfile = `${profile}/client`;
const payment = 'payment';
export const payment = 'payment';
export const paymentResult = `${payment}/result`;
const nutritionist = 'nutritionist';
......
export { default as useApi } from './useApi';
export { default as useAuthEffect } from './useAuthEffect';
export { default as useDownloadFiles } from './useDownloadFiles';
export { default as useForm } from './useForm';
export { default as useLinkingEffect } from './useLinkingEffect';
export { default as useSignupEffect } from './useSignupEffect';
import { useContext, useEffect, useCallback, useState } from 'react';
import { useEffect, useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { UserContext } from 'provider';
import * as ROUTES from 'constants/routes';
import CACHE_KEYS from 'constants/cacheKeys';
import { getCache } from 'utils/cache';
const useAuthEffect = () => {
const { isFirstLoading } = useContext(UserContext);
const useSignupEffect = () => {
const navigation = useNavigation();
const [isLoading, setIsLoading] = useState(false);
const checkCart = useCallback(async () => {
setIsLoading(true);
const dietProfileId = await getCache(CACHE_KEYS.dietProfileId);
const cartId = await getCache(CACHE_KEYS.cartId);
if (!dietProfileId) {
navigation.reset({
index: 0,
......@@ -22,20 +17,12 @@ const useAuthEffect = () => {
{ name: ROUTES.allAccessQuestionnaire },
],
});
} else if (!cartId) {
navigation.reset({
index: 0,
routes: [{ name: ROUTES.initial }, { name: ROUTES.choosePlan }],
});
}
setIsLoading(false);
}, [navigation]);
useEffect(() => {
checkCart();
}, [checkCart]);
return isFirstLoading || isLoading;
};
export default useAuthEffect;
export default useSignupEffect;
......@@ -21,14 +21,13 @@ import {
import { setAuthHeader, resetAuthHeader } from 'services/api';
import { iUserContext } from './types';
import { TransactionStatus } from 'services/payment/models';
export const initialUser = {
id: null,
email: '',
name: '',
role: null,
transaction_status: TransactionStatus.UNPAID,
transaction_status: null,
is_finished_onboarding: false,
};
......@@ -53,8 +52,8 @@ export const useUserContext = (): iUserContext => {
await GoogleSignin.signOut();
await removeCache(CACHE_KEYS.authToken);
await removeCache(CACHE_KEYS.refreshToken);
setUser(initialUser);
resetAuthHeader();
setUser(initialUser);
}, []);
const getUser = useCallback(async () => {
......@@ -81,8 +80,8 @@ export const useUserContext = (): iUserContext => {
const accessToken = data.access_token;
await setCache(CACHE_KEYS.authToken, accessToken);
await setCache(CACHE_KEYS.refreshToken, data.refresh_token);
setUser(data.user);
setAuthHeader(accessToken);
setUser(data.user);
};
const linkUserData = async (email: string) => {
......@@ -104,8 +103,8 @@ export const useUserContext = (): iUserContext => {
const signup = async (registerData: RegistrationRequest) => {
const response = await signupApi(registerData);
if (response.success && response.data) {
await linkUserData(response.data.user.email);
await authSuccess(response.data);
return await linkUserData(response.data.user.email);
}
return response;
};
......@@ -127,8 +126,7 @@ export const useUserContext = (): iUserContext => {
access_token: tokens.accessToken,
});
if (response.success && response.data) {
await authSuccess(response.data);
// If signup, link user to cart and diet profile
if (!isLogin) {
const linkResponse = await linkUserData(response.data.user.email);
if (!linkResponse.success) {
......@@ -140,6 +138,8 @@ export const useUserContext = (): iUserContext => {
});
}
}
await authSuccess(response.data);
} else {
await logout();
}
......
import React, { FC, useContext } from 'react';
import { useForm, useAuthEffect } from 'hooks';
import { useForm, useSignupEffect } from 'hooks';
import { ScrollView } from 'react-native-gesture-handler';
import { useNavigation } from '@react-navigation/core';
import { BigButton, Link, Toast, Loader } from 'components/core';
import { BigButton, Link, Toast } from 'components/core';
import { Section } from 'components/layout';
import { TextField } from 'components/form';
import { GoogleLoginButton } from '../components';
......@@ -50,11 +50,8 @@ const ManualRegistrationPage: FC = () => {
const signupWithGoogle = () => loginWithGoogle(false);
const isProcessing = useAuthEffect();
useSignupEffect();
if (isProcessing) {
return <Loader />;
}
return (
<ScrollView contentContainerStyle={layoutStyles}>
{textField.map((fieldProps, i) => (
......
import React from 'react';
import { render, waitFor, fireEvent } from 'utils/testing';
import { render, waitFor, fireEvent } from '@testing-library/react-native';
import axios from 'axios';
import Checkout from '.';
import * as ROUTES from 'constants/routes';
import CACHE_KEYS from 'constants/cacheKeys';
import { DietelaProgram } from 'services/dietelaQuiz/quizResult';
import { setCache } from 'utils/cache';
import { mockProgramRecommendations } from '__mocks__/quizResult';
import { Linking } from 'react-native';
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => {
return {
useNavigation: () => ({
navigate: mockedNavigate,
reset: mockedNavigate,
}),
};
});
describe('Checkout', () => {
const nutritionist = {
id: 1,
......@@ -38,18 +47,11 @@ describe('Checkout', () => {
},
});
const userContextMock = {
isAuthenticated: true,
isUnpaidClient: true,
};
it('redirects to program detail page when user clicks Baca selengkapnya button for program', async () => {
await setCache(CACHE_KEYS.cartId, 1);
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getAllByText, getByText } = render(<Checkout />, ROUTES.checkout, {
userContext: userContextMock,
});
const { getAllByText, getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const chosenProgram = getByText(/One Week Trial/i);
......@@ -59,16 +61,13 @@ describe('Checkout', () => {
expect(readMoreButton).toBeTruthy();
fireEvent.press(readMoreButton);
const programDetailPage = getByText(/Program Dietela/i);
expect(programDetailPage).toBeTruthy();
expect(mockedNavigate).toHaveBeenCalled();
});
it('redirects to nutritionist detail page when user clicks Baca selengkapnya button for nutritionist', async () => {
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getAllByText, getByText } = render(<Checkout />, ROUTES.checkout, {
userContext: userContextMock,
});
const { getAllByText, getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const chosenNutritionist = getByText(/Wendy/i);
......@@ -77,6 +76,8 @@ describe('Checkout', () => {
const readMoreButton = getAllByText(/Baca selengkapnya/i)[1];
expect(readMoreButton).toBeTruthy();
fireEvent.press(readMoreButton);
expect(mockedNavigate).toHaveBeenCalled();
});
it('redirects to choose plan page when user clicks Ganti Pilihan button', async () => {
......@@ -86,25 +87,20 @@ describe('Checkout', () => {
);
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getByText } = render(<Checkout />, ROUTES.checkout, {
userContext: userContextMock,
});
const { getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const changePlanButton = getByText(/ganti pilihan/i);
expect(changePlanButton).toBeTruthy();
await waitFor(() => fireEvent.press(changePlanButton));
const choosePlanPage = getByText(/Choose Plan/i);
expect(choosePlanPage).toBeTruthy();
expect(mockedNavigate).toHaveBeenCalled();
});
it('call Linking open url when user clicks Bayar button and submit successful', async () => {
it('redirect to payment webview when user clicks Bayar button and submit successful', async () => {
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getByText } = render(<Checkout />, ROUTES.checkout, {
userContext: userContextMock,
});
const { getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const payWithMidtransApi = () =>
......@@ -115,22 +111,18 @@ describe('Checkout', () => {
},
});
mockAxios.request.mockImplementationOnce(payWithMidtransApi);
const spy = jest.spyOn(Linking, 'openURL');
const payButton = getByText(/bayar dengan midtrans/i);
expect(payButton).toBeTruthy();
await waitFor(() => fireEvent.press(payButton));
expect(spy).toHaveBeenCalled();
spy.mockReset();
expect(mockedNavigate).toHaveBeenCalled();
});
it('does not call Linking open url when user clicks Bayar button but submit fails', async () => {
it('does not redirect to payment when user clicks Bayar button but submit fails', async () => {
mockAxios.request.mockImplementationOnce(retrieveCartApi);
const { getByText } = render(<Checkout />, ROUTES.checkout, {
userContext: userContextMock,
});
const { getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
const payWithMidtransApi = () =>
......@@ -141,14 +133,27 @@ describe('Checkout', () => {
},
});
mockAxios.request.mockImplementationOnce(payWithMidtransApi);
const spy = jest.spyOn(Linking, 'openURL');
const payButton = getByText(/bayar dengan midtrans/i);
expect(payButton).toBeTruthy();
await waitFor(() => fireEvent.press(payButton));
});
it('shows empty data page when fetch cart fails', async () => {
mockAxios.request.mockImplementationOnce(() =>
Promise.resolve({
status: 400,
response: {
data: undefined,
},
}),
);
const { getByText } = render(<Checkout />);
await waitFor(() => expect(mockAxios.request).toBeCalled());
expect(spy).not.toHaveBeenCalled();
spy.mockReset();
const emptyDataPage = getByText(/Anda belum memilih program diet/i);
expect(emptyDataPage).toBeTruthy();
});
afterAll(() => {
......
import React, { FC, useCallback, useState } from 'react';
import { View, Linking } from 'react-native';
import React, { FC } from 'react';
import { View } from 'react-native';
import { Text, Button } from 'react-native-elements';
import { useNavigation } from '@react-navigation/native';
......@@ -8,79 +8,75 @@ import { Section } from 'components/layout';
import CACHE_KEYS from 'constants/cacheKeys';
import * as ROUTES from 'constants/routes';
import { dietPrograms } from 'constants/dietelaProgram';
import { useApi, useLinkingEffect } from 'hooks';
import { useApi } from 'hooks';
import { retrieveCartApi, payWithMidtransApi } from 'services/payment';
import { getCache } from 'utils/cache';
import { typographyStyles } from 'styles';
import { styles } from './styles';
import { CheckoutCard } from './components';
import EmptyDataPage from 'components/core/EmptyDataPage';
const Checkout: FC = () => {
const navigation = useNavigation();
const [cartId, setCartId] = useState<string | null>(null);
const fetchCart = useCallback(async () => {
const fetchCart = async () => {
const cachedCartId = await getCache(CACHE_KEYS.cartId);
setCartId(cachedCartId);
return await retrieveCartApi(cachedCartId);
}, [setCartId]);
};
const { isLoading, data } = useApi(fetchCart);
const pay = async () => {
const response = await payWithMidtransApi(cartId);
const response = await payWithMidtransApi(data?.id);
if (response.success && response.data) {
await Linking.openURL(response.data.redirect_url);
navigation.navigate(ROUTES.payment, { url: response.data.redirect_url });
} else {
Toast.show({
type: 'error',
text1: 'Gagal melakukan transaksi pembayaran.',
text2: 'Terjadi kesalahan pada sisi kami. Silakan coba lagi',
text1: 'Anda sudah membayar tagihan ini.',
text2:
'Mohon restart aplikasi Dietela untuk memulai perjalanan diet Anda.',
});
}
};
useLinkingEffect();
if (isLoading) {
return <Loader />;
}
if (!data) {
return <EmptyDataPage text="Anda belum memilih program diet" />;
}
return (
<View style={styles.container}>
<View>
<CheckoutCard
content={data ? dietPrograms[data.program.unique_code].title : '-'}
content={dietPrograms[data.program.unique_code].title}
type="program"
onReadMore={() =>
navigation.navigate(ROUTES.programDetail, {
id: data?.program.unique_code,
id: data.program.unique_code,
})
}
/>
<CheckoutCard
content={data ? data.nutritionist.full_name_and_degree : '-'}
content={data.nutritionist.full_name_and_degree}
type="nutritionist"
onReadMore={() =>
navigation.navigate(ROUTES.nutritionistDetail, {
ntr: data?.nutritionist,
ntr: data.nutritionist,
})
}
/>
</View>
<View style={styles.priceContainer}>
<Text style={typographyStyles.headingMedium}>Harga:</Text>
{data ? (
<View style={styles.currencyContainer}>
<Text style={styles.currency}>Rp</Text>
<Text style={styles.basePrice}>