feat: add transaction filters

+2 -2
app/app.json
··· 33 "image": "./assets/images/splash-icon.png", 34 "imageWidth": 200, 35 "resizeMode": "contain", 36 - "backgroundColor": "#ffffff", 37 "dark": { 38 - "backgroundColor": "#000000" 39 } 40 } 41 ],
··· 33 "image": "./assets/images/splash-icon.png", 34 "imageWidth": 200, 35 "resizeMode": "contain", 36 + "backgroundColor": "#EFF1F5", 37 "dark": { 38 + "backgroundColor": "#1E1E2E" 39 } 40 } 41 ],
+78 -15
app/app/(auth)/index.tsx
··· 1 import { StyleSheet } from "react-native"; 2 3 - import { ThemedText } from "@/components/themed-text"; 4 - import { ThemedView } from "@/components/themed-view"; 5 import { useLogin } from "@/hooks/use-login"; 6 - import { Button } from "@react-navigation/elements"; 7 8 - export default function HomeScreen() { 9 - const { login } = useLogin(); 10 11 return ( 12 - <ThemedView style={styles.titleContainer}> 13 - <ThemedText type="title">Welcome!</ThemedText> 14 - <Button 15 - onPressOut={() => 16 - login({ username: "test@test.test", password: "password@123" }) 17 - } 18 - > 19 - login 20 - </Button> 21 - </ThemedView> 22 ); 23 } 24 25 const styles = StyleSheet.create({ 26 titleContainer: { 27 paddingTop: 60, 28 flexDirection: "row", 29 alignItems: "center", 30 gap: 8, 31 }, 32 });
··· 1 + import { useState } from "react"; 2 import { StyleSheet } from "react-native"; 3 + import { useRouter } from "expo-router"; 4 5 + import ContainerView from "@/components/container-view"; 6 + import { LoginRequest } from "@/generated"; 7 + import { 8 + ThemedText, 9 + ThemedTextInput, 10 + ThemedView, 11 + ThemedButton, 12 + ThemedErrorBox, 13 + } from "@/components/theme"; 14 import { useLogin } from "@/hooks/use-login"; 15 + import { useThemeColor } from "@/hooks/use-theme-color"; 16 17 + export default function LoginScreen() { 18 + const { login, loading, error } = useLogin(); 19 + const router = useRouter(); 20 + const borderColor = useThemeColor({}, "text"); 21 + 22 + const [formData, setFormData] = useState<LoginRequest>({ 23 + username: "", 24 + password: "", 25 + }); 26 27 return ( 28 + <ContainerView style={styles.page}> 29 + <ThemedView style={[styles.formContainer, { borderColor }]}> 30 + <ThemedText type="title">Welcome!</ThemedText> 31 + <ThemedTextInput 32 + autoComplete="email" 33 + disabled={loading} 34 + nextHint="next" 35 + placeholder="Username" 36 + value={formData.username} 37 + error={error?.properties?.username?.errors} 38 + onChange={(username) => 39 + setFormData((state) => ({ ...state, username })) 40 + } 41 + /> 42 + <ThemedTextInput 43 + autoComplete="current-password" 44 + disabled={loading} 45 + nextHint="go" 46 + placeholder="Password" 47 + type="password" 48 + value={formData.password} 49 + error={error?.properties?.password?.errors} 50 + onChange={(password) => 51 + setFormData((state) => ({ ...state, password })) 52 + } 53 + /> 54 + <ThemedErrorBox errors={error?.errors} /> 55 + <ThemedView style={styles.buttonGroup}> 56 + <ThemedButton disabled={loading} onPress={() => login(formData)}> 57 + {loading ? "loading.." : "login"} 58 + </ThemedButton> 59 + <ThemedButton 60 + disabled={loading} 61 + onPress={() => router.push("./settings")} 62 + > 63 + settings 64 + </ThemedButton> 65 + </ThemedView> 66 + </ThemedView> 67 + </ContainerView> 68 ); 69 } 70 71 const styles = StyleSheet.create({ 72 + page: { 73 + flex: 1, 74 + justifyContent: "center", 75 + alignItems: "center", 76 + flexDirection: "row", 77 + }, 78 titleContainer: { 79 paddingTop: 60, 80 flexDirection: "row", 81 alignItems: "center", 82 gap: 8, 83 + }, 84 + formContainer: { 85 + borderRadius: 8, 86 + borderWidth: 1, 87 + padding: 16, 88 + display: "flex", 89 + }, 90 + buttonGroup: { 91 + display: "flex", 92 + flexDirection: "row", 93 + justifyContent: "space-around", 94 }, 95 });
+10 -16
app/app/(auth)/settings.tsx
··· 1 - import { StyleSheet } from "react-native"; 2 3 - import { ThemedText } from "@/components/themed-text"; 4 - import { ThemedView } from "@/components/themed-view"; 5 - 6 - export default function SettingsScreen() { 7 return ( 8 - <ThemedView style={styles.titleContainer}> 9 - <ThemedText type="title">Settings</ThemedText> 10 - </ThemedView> 11 ); 12 } 13 - 14 - const styles = StyleSheet.create({ 15 - titleContainer: { 16 - flexDirection: "row", 17 - alignItems: "center", 18 - gap: 8, 19 - }, 20 - });
··· 1 + import ContainerView from "@/components/container-view"; 2 + import { ThemedText, ThemedView } from "@/components/theme"; 3 + import { ThemeToggle } from "@/components/shared/theme-toggle"; 4 5 + export default function LoginSettingsScreen() { 6 return ( 7 + <ContainerView> 8 + <ThemedView> 9 + <ThemedText type="title">Settings</ThemedText> 10 + </ThemedView> 11 + <ThemeToggle /> 12 + </ContainerView> 13 ); 14 }
+30 -16
app/app/(tabs)/_layout.tsx
··· 1 - import { Tabs } from 'expo-router'; 2 - import React from 'react'; 3 4 - import { HapticTab } from '@/components/haptic-tab'; 5 - import { IconSymbol } from '@/components/ui/icon-symbol'; 6 - import { Colors } from '@/constants/theme'; 7 - import { useColorScheme } from '@/hooks/use-color-scheme'; 8 - import { useAuthentication } from '@/hooks/use-authentication'; 9 10 export default function TabLayout() { 11 const colorScheme = useColorScheme(); ··· 15 return ( 16 <Tabs 17 screenOptions={{ 18 - tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, 19 headerShown: false, 20 tabBarButton: HapticTab, 21 - }}> 22 <Tabs.Screen 23 name="index" 24 options={{ 25 - title: 'Home', 26 - tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />, 27 }} 28 /> 29 <Tabs.Screen 30 - name="explore" 31 options={{ 32 - title: 'Explore', 33 - tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />, 34 }} 35 /> 36 <Tabs.Screen 37 name="account" 38 options={{ 39 - title: 'Account', 40 - tabBarIcon: ({ color }) => <IconSymbol size={28} name="chevron.right" color={color} />, 41 }} 42 /> 43 </Tabs>
··· 1 + import { Tabs } from "expo-router"; 2 + import React from "react"; 3 4 + import { HapticTab } from "@/components/haptic-tab"; 5 + import { IconSymbol } from "@/components/ui/icon-symbol"; 6 + import { Colors } from "@/constants/theme"; 7 + import { useColorScheme } from "@/hooks/use-color-scheme"; 8 + import { useAuthentication } from "@/hooks/use-authentication"; 9 10 export default function TabLayout() { 11 const colorScheme = useColorScheme(); ··· 15 return ( 16 <Tabs 17 screenOptions={{ 18 + tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint, 19 headerShown: false, 20 tabBarButton: HapticTab, 21 + }} 22 + > 23 <Tabs.Screen 24 name="index" 25 options={{ 26 + title: "Home", 27 + tabBarIcon: ({ color }) => ( 28 + <IconSymbol size={28} name="house.fill" color={color} /> 29 + ), 30 }} 31 /> 32 <Tabs.Screen 33 + name="cards" 34 options={{ 35 + title: "Cards", 36 + tabBarIcon: ({ color }) => ( 37 + <IconSymbol size={28} name="creditcard.fill" color={color} /> 38 + ), 39 }} 40 /> 41 <Tabs.Screen 42 name="account" 43 options={{ 44 + title: "Settings", 45 + tabBarIcon: ({ color }) => ( 46 + <IconSymbol size={28} name="shield.fill" color={color} /> 47 + ), 48 + }} 49 + /> 50 + <Tabs.Screen 51 + name="accounts/[accountId]/index" 52 + options={{ 53 + href: null, 54 + title: "Transactions", 55 }} 56 /> 57 </Tabs>
+14 -10
app/app/(tabs)/account.tsx
··· 1 import { Image } from "expo-image"; 2 import { StyleSheet } from "react-native"; 3 import ParallaxScrollView from "@/components/parallax-scroll-view"; 4 - import { ThemedText } from "@/components/themed-text"; 5 - import { ThemedView } from "@/components/themed-view"; 6 - import { Button } from "@react-navigation/elements"; 7 import { useCurrentUser } from "@/hooks/use-current-user"; 8 import { useLogout } from "@/hooks/use-logout"; 9 10 - export default function ProfileScreen() { 11 const { logout } = useLogout(); 12 13 const { user } = useCurrentUser(); ··· 23 } 24 > 25 <ThemedView style={styles.titleContainer}> 26 - <ThemedText type="title">{`Welcome ${user?.fullname}`}</ThemedText> 27 - </ThemedView> 28 - <ThemedView style={styles.titleContainer}> 29 - <ThemedText type="subtitle">{`username: ${user?.username}`}</ThemedText> 30 </ThemedView> 31 - <ThemedView style={styles.titleContainer}> 32 - <Button onPressOut={() => logout()}>logout</Button> 33 </ThemedView> 34 </ParallaxScrollView> 35 ); 36 } ··· 38 const styles = StyleSheet.create({ 39 titleContainer: { 40 flexDirection: "row", 41 alignItems: "center", 42 gap: 8, 43 },
··· 1 import { Image } from "expo-image"; 2 import { StyleSheet } from "react-native"; 3 import ParallaxScrollView from "@/components/parallax-scroll-view"; 4 + import { ThemedText, ThemedView, ThemedButton } from "@/components/theme"; 5 import { useCurrentUser } from "@/hooks/use-current-user"; 6 import { useLogout } from "@/hooks/use-logout"; 7 + import { ThemeToggle } from "@/components/shared/theme-toggle"; 8 9 + export default function SettingsScreen() { 10 const { logout } = useLogout(); 11 12 const { user } = useCurrentUser(); ··· 22 } 23 > 24 <ThemedView style={styles.titleContainer}> 25 + <ThemedText type="title">Settings</ThemedText> 26 </ThemedView> 27 + <ThemedView style={styles.settingsContainer}> 28 + <ThemedText>logged in as {user?.username}</ThemedText> 29 + <ThemedButton onPress={() => logout()}>logout</ThemedButton> 30 </ThemedView> 31 + <ThemeToggle /> 32 </ParallaxScrollView> 33 ); 34 } ··· 36 const styles = StyleSheet.create({ 37 titleContainer: { 38 flexDirection: "row", 39 + alignItems: "center", 40 + gap: 8, 41 + }, 42 + settingsContainer: { 43 + flexDirection: "row", 44 + justifyContent: "space-between", 45 alignItems: "center", 46 gap: 8, 47 },
+75
app/app/(tabs)/accounts/[accountId]/index.tsx
···
··· 1 + import { useState } from "react"; 2 + import { StyleSheet, TouchableOpacity, Modal } from "react-native"; 3 + import Ionicons from "@expo/vector-icons/Ionicons"; 4 + import { useLocalSearchParams } from "expo-router"; 5 + 6 + import { ThemedText, ThemedView } from "@/components/theme"; 7 + import { Price } from "@/components/ui/price"; 8 + import { TransactionList } from "@/components/transaction/transaction-list"; 9 + import ContainerView from "@/components/container-view"; 10 + import { useToggle } from "@/hooks/use-toggle"; 11 + import { useThemeColor } from "@/hooks/use-theme-color"; 12 + import { TransactionFilter } from "@/components/transaction/transaction-filter"; 13 + import { useAccount } from "@/hooks/use-account"; 14 + import { TransactionFilterOptions } from "@/hooks/use-transactions"; 15 + 16 + export default function AccountScreen() { 17 + const { isHidden, toggleHidden, setHidden } = useToggle(true); 18 + const [filter, setFilter] = useState<TransactionFilterOptions>({}); 19 + const color = useThemeColor({}, isHidden ? "text" : "green"); 20 + const { accountId: raw } = useLocalSearchParams<{ accountId: string }>(); 21 + const accountId = Number.parseInt(raw); 22 + 23 + const { account } = useAccount(accountId); 24 + 25 + return ( 26 + <ContainerView> 27 + <Modal 28 + visible={!isHidden} 29 + animationType="fade" 30 + onRequestClose={() => setHidden(false)} 31 + > 32 + <ThemedView style={styles.modalContainer}> 33 + <TransactionFilter 34 + initialState={filter} 35 + accountId={accountId} 36 + onCancel={() => setHidden(true)} 37 + onSubmit={(value) => { 38 + setFilter(value); 39 + setHidden(true); 40 + }} 41 + /> 42 + </ThemedView> 43 + </Modal> 44 + <ThemedView style={styles.titleContainer}> 45 + <ThemedView> 46 + <ThemedText type="default">{account?.name}</ThemedText> 47 + <ThemedText type="small">{account?.iban}</ThemedText> 48 + <Price>{account?.balance ?? 0}</Price> 49 + </ThemedView> 50 + <TouchableOpacity onPress={toggleHidden}> 51 + <ThemedView> 52 + <ThemedText type="default"> 53 + <Ionicons name="filter" size={32} color={color} /> 54 + </ThemedText> 55 + </ThemedView> 56 + </TouchableOpacity> 57 + </ThemedView> 58 + <TransactionList filter={filter} accountId={accountId} /> 59 + </ContainerView> 60 + ); 61 + } 62 + 63 + const styles = StyleSheet.create({ 64 + titleContainer: { 65 + flexDirection: "row", 66 + justifyContent: "space-between", 67 + alignItems: "center", 68 + }, 69 + modalContainer: { 70 + flex: 1, 71 + justifyContent: "center", 72 + alignItems: "center", 73 + backgroundColor: "none", 74 + }, 75 + });
+75
app/app/(tabs)/cards.tsx
···
··· 1 + import { Image } from "expo-image"; 2 + import { StyleSheet } from "react-native"; 3 + 4 + import ParallaxScrollView from "@/components/parallax-scroll-view"; 5 + import { ThemedText, ThemedView } from "@/components/theme"; 6 + import { useQuery } from "@tanstack/react-query"; 7 + import { getCardsOptions } from "@/generated/@tanstack/react-query.gen"; 8 + import { useCurrentUser } from "@/hooks/use-current-user"; 9 + import { AccountNumber } from "@/components/ui/accountnumber"; 10 + 11 + export default function HomeScreen() { 12 + const { data: cardData } = useQuery(getCardsOptions()); 13 + 14 + const { user } = useCurrentUser(); 15 + 16 + return ( 17 + <ParallaxScrollView 18 + headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }} 19 + headerImage={ 20 + <Image 21 + source={require("@/assets/images/partial-react-logo.png")} 22 + style={styles.reactLogo} 23 + /> 24 + } 25 + > 26 + <ThemedView style={styles.titleContainer}> 27 + <ThemedText type="subtitle">Welcome back, {user?.fullname}!</ThemedText> 28 + </ThemedView> 29 + <ThemedView style={styles.stepContainer}> 30 + {cardData?.map(({ id, number, cvv, expiry }) => ( 31 + <ThemedView style={styles.accountContainer} key={id}> 32 + <ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}> 33 + <AccountNumber>{number}</AccountNumber> 34 + </ThemedView> 35 + <ThemedView style={styles.accountDetail}> 36 + <ThemedText>CVV: {cvv}</ThemedText> 37 + <ThemedText>Expiry: {expiry}</ThemedText> 38 + </ThemedView> 39 + </ThemedView> 40 + ))} 41 + </ThemedView> 42 + </ParallaxScrollView> 43 + ); 44 + } 45 + 46 + const styles = StyleSheet.create({ 47 + titleContainer: { 48 + flexDirection: "row", 49 + alignItems: "center", 50 + gap: 8, 51 + }, 52 + stepContainer: { 53 + gap: 8, 54 + marginBottom: 8, 55 + }, 56 + reactLogo: { 57 + height: 178, 58 + width: 290, 59 + bottom: 0, 60 + left: 0, 61 + position: "absolute", 62 + }, 63 + accountContainer: { 64 + display: "flex", 65 + flexDirection: "row", 66 + justifyContent: "space-between", 67 + paddingVertical: 4, 68 + gap: 4, 69 + }, 70 + accountDetail: { 71 + flexDirection: "column", 72 + justifyContent: "center", 73 + paddingVertical: 2, 74 + }, 75 + });
-112
app/app/(tabs)/explore.tsx
··· 1 - import { Image } from 'expo-image'; 2 - import { Platform, StyleSheet } from 'react-native'; 3 - 4 - import { Collapsible } from '@/components/ui/collapsible'; 5 - import { ExternalLink } from '@/components/external-link'; 6 - import ParallaxScrollView from '@/components/parallax-scroll-view'; 7 - import { ThemedText } from '@/components/themed-text'; 8 - import { ThemedView } from '@/components/themed-view'; 9 - import { IconSymbol } from '@/components/ui/icon-symbol'; 10 - import { Fonts } from '@/constants/theme'; 11 - 12 - export default function TabTwoScreen() { 13 - return ( 14 - <ParallaxScrollView 15 - headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }} 16 - headerImage={ 17 - <IconSymbol 18 - size={310} 19 - color="#808080" 20 - name="chevron.left.forwardslash.chevron.right" 21 - style={styles.headerImage} 22 - /> 23 - }> 24 - <ThemedView style={styles.titleContainer}> 25 - <ThemedText 26 - type="title" 27 - style={{ 28 - fontFamily: Fonts.rounded, 29 - }}> 30 - Explore 31 - </ThemedText> 32 - </ThemedView> 33 - <ThemedText>This app includes example code to help you get started.</ThemedText> 34 - <Collapsible title="File-based routing"> 35 - <ThemedText> 36 - This app has two screens:{' '} 37 - <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '} 38 - <ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText> 39 - </ThemedText> 40 - <ThemedText> 41 - The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '} 42 - sets up the tab navigator. 43 - </ThemedText> 44 - <ExternalLink href="https://docs.expo.dev/router/introduction"> 45 - <ThemedText type="link">Learn more</ThemedText> 46 - </ExternalLink> 47 - </Collapsible> 48 - <Collapsible title="Android, iOS, and web support"> 49 - <ThemedText> 50 - You can open this project on Android, iOS, and the web. To open the web version, press{' '} 51 - <ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project. 52 - </ThemedText> 53 - </Collapsible> 54 - <Collapsible title="Images"> 55 - <ThemedText> 56 - For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '} 57 - <ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for 58 - different screen densities 59 - </ThemedText> 60 - <Image 61 - source={require('@/assets/images/react-logo.png')} 62 - style={{ width: 100, height: 100, alignSelf: 'center' }} 63 - /> 64 - <ExternalLink href="https://reactnative.dev/docs/images"> 65 - <ThemedText type="link">Learn more</ThemedText> 66 - </ExternalLink> 67 - </Collapsible> 68 - <Collapsible title="Light and dark mode components"> 69 - <ThemedText> 70 - This template has light and dark mode support. The{' '} 71 - <ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect 72 - what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly. 73 - </ThemedText> 74 - <ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/"> 75 - <ThemedText type="link">Learn more</ThemedText> 76 - </ExternalLink> 77 - </Collapsible> 78 - <Collapsible title="Animations"> 79 - <ThemedText> 80 - This template includes an example of an animated component. The{' '} 81 - <ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses 82 - the powerful{' '} 83 - <ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}> 84 - react-native-reanimated 85 - </ThemedText>{' '} 86 - library to create a waving hand animation. 87 - </ThemedText> 88 - {Platform.select({ 89 - ios: ( 90 - <ThemedText> 91 - The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '} 92 - component provides a parallax effect for the header image. 93 - </ThemedText> 94 - ), 95 - })} 96 - </Collapsible> 97 - </ParallaxScrollView> 98 - ); 99 - } 100 - 101 - const styles = StyleSheet.create({ 102 - headerImage: { 103 - color: '#808080', 104 - bottom: -90, 105 - left: -35, 106 - position: 'absolute', 107 - }, 108 - titleContainer: { 109 - flexDirection: 'row', 110 - gap: 8, 111 - }, 112 - });
···
+16 -36
app/app/(tabs)/index.tsx
··· 1 import { Image } from "expo-image"; 2 - import { StyleSheet, TouchableOpacity } from "react-native"; 3 4 import ParallaxScrollView from "@/components/parallax-scroll-view"; 5 - import { ThemedText } from "@/components/themed-text"; 6 - import { ThemedView } from "@/components/themed-view"; 7 - import { useQuery } from "@tanstack/react-query"; 8 import { getAccountsOptions } from "@/generated/@tanstack/react-query.gen"; 9 import { useCurrentUser } from "@/hooks/use-current-user"; 10 - import { IconSymbol } from "@/components/ui/icon-symbol"; 11 - import { Price } from "@/components/ui/price"; 12 - import { AccountNumber } from "@/components/ui/accountnumber"; 13 14 export default function HomeScreen() { 15 const { data: accountData } = useQuery(getAccountsOptions()); 16 17 const { user } = useCurrentUser(); 18 19 return ( 20 <ParallaxScrollView ··· 27 } 28 > 29 <ThemedView style={styles.titleContainer}> 30 - <ThemedText type="default">Welcome back, {user?.fullname}!</ThemedText> 31 </ThemedView> 32 <ThemedView style={styles.stepContainer}> 33 - {accountData?.map(({ id, name, balance, iban }) => ( 34 - <TouchableOpacity key={id}> 35 - <ThemedView style={styles.accountContainer}> 36 - <ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}> 37 - <ThemedText>{name}</ThemedText> 38 - <AccountNumber hidden>{iban}</AccountNumber> 39 - </ThemedView> 40 - <ThemedView style={styles.accountDetail}> 41 - <Price>{balance}</Price> 42 - <ThemedText>+20%</ThemedText> 43 - </ThemedView> 44 - <ThemedView style={styles.accountDetail}> 45 - <IconSymbol size={20} name="chevron.right" color="black" /> 46 - </ThemedView> 47 - </ThemedView> 48 - </TouchableOpacity> 49 ))} 50 </ThemedView> 51 </ParallaxScrollView> ··· 68 bottom: 0, 69 left: 0, 70 position: "absolute", 71 - }, 72 - accountContainer: { 73 - display: "flex", 74 - flexDirection: "row", 75 - justifyContent: "space-between", 76 - paddingVertical: 4, 77 - gap: 4, 78 - }, 79 - accountDetail: { 80 - flexDirection: "column", 81 - justifyContent: "center", 82 - paddingVertical: 2, 83 }, 84 });
··· 1 import { Image } from "expo-image"; 2 + import { StyleSheet } from "react-native"; 3 + import { useRouter } from "expo-router"; 4 + import { useQuery } from "@tanstack/react-query"; 5 6 import ParallaxScrollView from "@/components/parallax-scroll-view"; 7 + import { ThemedText, ThemedView } from "@/components/theme"; 8 import { getAccountsOptions } from "@/generated/@tanstack/react-query.gen"; 9 import { useCurrentUser } from "@/hooks/use-current-user"; 10 + import { AccountCard } from "@/components/account-card"; 11 12 export default function HomeScreen() { 13 const { data: accountData } = useQuery(getAccountsOptions()); 14 15 const { user } = useCurrentUser(); 16 + const router = useRouter(); 17 18 return ( 19 <ParallaxScrollView ··· 26 } 27 > 28 <ThemedView style={styles.titleContainer}> 29 + <ThemedText type="subtitle">Bank Accounts</ThemedText> 30 + </ThemedView> 31 + <ThemedView style={styles.titleContainer}> 32 + <ThemedText type="small">welcome back, {user?.fullname}</ThemedText> 33 </ThemedView> 34 <ThemedView style={styles.stepContainer}> 35 + {accountData?.map((account) => ( 36 + <AccountCard 37 + key={account.id} 38 + account={account} 39 + onPress={() => router.push(`./accounts/${account.id}`)} 40 + /> 41 ))} 42 </ThemedView> 43 </ParallaxScrollView> ··· 60 bottom: 0, 61 left: 0, 62 position: "absolute", 63 }, 64 });
+3 -1
app/app/_layout.tsx
··· 24 <ThemeProvider 25 value={colorScheme === "dark" ? DarkTheme : DefaultTheme} 26 > 27 - <Stack screenOptions={{ headerShown: false, navigationBarHidden: true}}> 28 <Stack.Screen 29 name="(tabs)" 30 options={{ headerShown: false, navigationBarHidden: true }}
··· 24 <ThemeProvider 25 value={colorScheme === "dark" ? DarkTheme : DefaultTheme} 26 > 27 + <Stack 28 + screenOptions={{ headerShown: false, navigationBarHidden: true }} 29 + > 30 <Stack.Screen 31 name="(tabs)" 32 options={{ headerShown: false, navigationBarHidden: true }}
+66
app/components/account-card.tsx
···
··· 1 + import { StyleSheet, TouchableOpacity } from "react-native"; 2 + 3 + import { IconSymbol } from "@/components/ui/icon-symbol"; 4 + import { Price } from "@/components/ui/price"; 5 + import { AccountNumber } from "@/components/ui/accountnumber"; 6 + import { ThemedText, ThemedView } from "@/components/theme"; 7 + import { Account } from "@/generated"; 8 + import { useThemeColor } from "@/hooks/use-theme-color"; 9 + 10 + interface AccountCardProps { 11 + account: Account; 12 + onPress?: (account: Account) => void | Promise<void>; 13 + } 14 + 15 + export const AccountCard = ({ account, onPress }: AccountCardProps) => { 16 + const chevronColor = useThemeColor({}, "text"); 17 + 18 + const { id, name, balance, iban } = account; 19 + const rate = 50 - Number(iban.slice(-2)); 20 + 21 + const color = useThemeColor( 22 + {}, 23 + Number.isNaN(rate) ? "text" : rate > 0 ? "green" : "red", 24 + ); 25 + 26 + return ( 27 + <TouchableOpacity key={id} onPress={() => onPress?.(account)}> 28 + <ThemedView style={styles.accountContainer}> 29 + <ThemedView style={[styles.accountDetail, { flexGrow: 1 }]}> 30 + <ThemedText>{name}</ThemedText> 31 + <AccountNumber hidden size="small"> 32 + {iban} 33 + </AccountNumber> 34 + </ThemedView> 35 + <ThemedView style={[styles.accountDetail, styles.balanceDetail]}> 36 + <Price>{balance}</Price> 37 + <ThemedText style={{ color }}> 38 + {rate > 0 ? "+" : ""} 39 + {Number.isNaN(rate) ? 0 : rate}% 40 + </ThemedText> 41 + </ThemedView> 42 + <ThemedView style={styles.accountDetail}> 43 + <IconSymbol size={14} name="chevron.right" color={chevronColor} /> 44 + </ThemedView> 45 + </ThemedView> 46 + </TouchableOpacity> 47 + ); 48 + }; 49 + 50 + const styles = StyleSheet.create({ 51 + accountContainer: { 52 + display: "flex", 53 + flexDirection: "row", 54 + justifyContent: "space-between", 55 + paddingVertical: 4, 56 + gap: 4, 57 + }, 58 + accountDetail: { 59 + flexDirection: "column", 60 + justifyContent: "center", 61 + paddingVertical: 2, 62 + }, 63 + balanceDetail: { 64 + alignItems: "flex-end", 65 + }, 66 + });
+48
app/components/container-view.tsx
···
··· 1 + import type { PropsWithChildren, ReactElement } from "react"; 2 + import { StyleSheet, ViewStyle } from "react-native"; 3 + 4 + import { ThemedView } from "@/components/theme"; 5 + import { useThemeColor } from "@/hooks/use-theme-color"; 6 + 7 + const HEADER_HEIGHT = 250; 8 + 9 + type Props = PropsWithChildren<{ 10 + headerImage?: ReactElement; 11 + headerBackgroundColor?: { dark: string; light: string }; 12 + style?: ViewStyle; 13 + }>; 14 + 15 + export default function ContainerView({ 16 + children, 17 + headerImage, 18 + style = {}, 19 + }: Props) { 20 + const backgroundColor = useThemeColor({}, "background"); 21 + 22 + return ( 23 + <ThemedView style={[styles.container, { backgroundColor }, style]}> 24 + {headerImage ? ( 25 + <ThemedView style={styles.header}>{headerImage}</ThemedView> 26 + ) : null} 27 + <ThemedView style={styles.content}>{children}</ThemedView> 28 + </ThemedView> 29 + ); 30 + } 31 + 32 + const styles = StyleSheet.create({ 33 + container: { 34 + flex: 1, 35 + paddingTop: 25, 36 + justifyContent: "space-between", 37 + }, 38 + header: { 39 + height: HEADER_HEIGHT, 40 + overflow: "hidden", 41 + }, 42 + content: { 43 + flex: 1, 44 + padding: 32, 45 + gap: 16, 46 + overflow: "hidden", 47 + }, 48 + });
+32 -28
app/components/parallax-scroll-view.tsx
··· 1 - import type { PropsWithChildren, ReactElement } from 'react'; 2 - import { StyleSheet } from 'react-native'; 3 import Animated, { 4 interpolate, 5 useAnimatedRef, 6 useAnimatedStyle, 7 useScrollOffset, 8 - } from 'react-native-reanimated'; 9 10 - import { ThemedView } from '@/components/themed-view'; 11 - import { useColorScheme } from '@/hooks/use-color-scheme'; 12 - import { useThemeColor } from '@/hooks/use-theme-color'; 13 14 const HEADER_HEIGHT = 250; 15 ··· 23 headerImage, 24 headerBackgroundColor, 25 }: Props) { 26 - const backgroundColor = useThemeColor({}, 'background'); 27 - const colorScheme = useColorScheme() ?? 'light'; 28 const scrollRef = useAnimatedRef<Animated.ScrollView>(); 29 const scrollOffset = useScrollOffset(scrollRef); 30 - const headerAnimatedStyle = useAnimatedStyle(() => { 31 - return { 32 - transform: [ 33 - { 34 - translateY: interpolate( 35 - scrollOffset.value, 36 - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 37 - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] 38 - ), 39 - }, 40 - { 41 - scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), 42 - }, 43 - ], 44 - }; 45 - }); 46 47 return ( 48 <Animated.ScrollView 49 ref={scrollRef} 50 style={{ backgroundColor, flex: 1 }} 51 - scrollEventThrottle={16}> 52 <Animated.View 53 style={[ 54 styles.header, 55 { backgroundColor: headerBackgroundColor[colorScheme] }, 56 headerAnimatedStyle, 57 - ]}> 58 {headerImage} 59 </Animated.View> 60 <ThemedView style={styles.content}>{children}</ThemedView> ··· 68 }, 69 header: { 70 height: HEADER_HEIGHT, 71 - overflow: 'hidden', 72 }, 73 content: { 74 flex: 1, 75 padding: 32, 76 gap: 16, 77 - overflow: 'hidden', 78 }, 79 });
··· 1 + import type { PropsWithChildren, ReactElement } from "react"; 2 + import { StyleSheet } from "react-native"; 3 import Animated, { 4 interpolate, 5 useAnimatedRef, 6 useAnimatedStyle, 7 useScrollOffset, 8 + } from "react-native-reanimated"; 9 10 + import { ThemedView } from "@/components/theme"; 11 + import { useColorScheme } from "@/hooks/use-color-scheme"; 12 + import { useThemeColor } from "@/hooks/use-theme-color"; 13 14 const HEADER_HEIGHT = 250; 15 ··· 23 headerImage, 24 headerBackgroundColor, 25 }: Props) { 26 + const backgroundColor = useThemeColor({}, "background"); 27 + const colorScheme = useColorScheme() ?? "light"; 28 const scrollRef = useAnimatedRef<Animated.ScrollView>(); 29 const scrollOffset = useScrollOffset(scrollRef); 30 + const headerAnimatedStyle = useAnimatedStyle(() => ({ 31 + transform: [ 32 + { 33 + translateY: interpolate( 34 + scrollOffset.value, 35 + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 36 + [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], 37 + ), 38 + }, 39 + { 40 + scale: interpolate( 41 + scrollOffset.value, 42 + [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 43 + [2, 1, 1], 44 + ), 45 + }, 46 + ], 47 + })); 48 49 return ( 50 <Animated.ScrollView 51 ref={scrollRef} 52 style={{ backgroundColor, flex: 1 }} 53 + scrollEventThrottle={16} 54 + > 55 <Animated.View 56 style={[ 57 styles.header, 58 { backgroundColor: headerBackgroundColor[colorScheme] }, 59 headerAnimatedStyle, 60 + ]} 61 + > 62 {headerImage} 63 </Animated.View> 64 <ThemedView style={styles.content}>{children}</ThemedView> ··· 72 }, 73 header: { 74 height: HEADER_HEIGHT, 75 + overflow: "hidden", 76 }, 77 content: { 78 flex: 1, 79 padding: 32, 80 gap: 16, 81 + overflow: "hidden", 82 }, 83 });
+28
app/components/shared/theme-toggle.tsx
···
··· 1 + import { StyleSheet, Appearance, useColorScheme } from "react-native"; 2 + import { ThemedText, ThemedView, ThemedButton } from "@/components/theme"; 3 + 4 + export const ThemeToggle = () => { 5 + const colorScheme = useColorScheme(); 6 + 7 + return ( 8 + <ThemedView style={styles.settingsContainer}> 9 + <ThemedText>colorscheme set to {colorScheme}</ThemedText> 10 + <ThemedButton 11 + onPress={() => 12 + Appearance.setColorScheme(colorScheme === "dark" ? "light" : "dark") 13 + } 14 + > 15 + toggle 16 + </ThemedButton> 17 + </ThemedView> 18 + ); 19 + }; 20 + 21 + const styles = StyleSheet.create({ 22 + settingsContainer: { 23 + flexDirection: "row", 24 + justifyContent: "space-between", 25 + alignItems: "center", 26 + gap: 8, 27 + }, 28 + });
+6
app/components/theme/index.tsx
···
··· 1 + export * from "./themed-button"; 2 + export * from "./themed-error"; 3 + export * from "./themed-error-box"; 4 + export * from "./themed-text"; 5 + export * from "./themed-text-input"; 6 + export * from "./themed-view";
+44
app/components/theme/themed-button.tsx
···
··· 1 + import { StyleSheet, TouchableOpacity } from "react-native"; 2 + import { ThemedText, ThemedView } from "@/components/theme"; 3 + import { useThemeColor } from "@/hooks/use-theme-color"; 4 + 5 + interface ThemedButtonProps { 6 + ariaText?: string; 7 + children: string; 8 + disabled?: boolean; 9 + onPress: () => void | Promise<void>; 10 + } 11 + 12 + export const ThemedButton = ({ 13 + ariaText, 14 + children, 15 + disabled, 16 + onPress, 17 + }: ThemedButtonProps) => { 18 + const backgroundColor = useThemeColor({}, "background"); 19 + const color = useThemeColor({}, "text"); 20 + 21 + return ( 22 + <TouchableOpacity 23 + accessibilityLabel={ariaText} 24 + onPress={async () => (!disabled ? await onPress() : null)} 25 + > 26 + <ThemedView 27 + style={[styles.container, { backgroundColor, borderColor: color }]} 28 + > 29 + <ThemedText style={[styles.text, { color }]}>{children}</ThemedText> 30 + </ThemedView> 31 + </TouchableOpacity> 32 + ); 33 + }; 34 + 35 + const styles = StyleSheet.create({ 36 + container: { 37 + paddingHorizontal: 8, 38 + paddingVertical: 4, 39 + borderRadius: 8, 40 + display: "flex", 41 + borderWidth: 1, 42 + }, 43 + text: {}, 44 + });
+26
app/components/theme/themed-error-box.tsx
···
··· 1 + import { StyleSheet } from "react-native"; 2 + import { ThemedView } from "./themed-view"; 3 + import { wrap, MaybeArray } from "@/services/utils"; 4 + import { ThemedError } from "./themed-error"; 5 + 6 + interface ThemedErrorBoxProps { 7 + errors?: MaybeArray<Error | string>; 8 + } 9 + 10 + export const ThemedErrorBox = ({ errors }: ThemedErrorBoxProps) => { 11 + return errors && wrap(errors).length > 0 ? ( 12 + <ThemedView style={styles.container}> 13 + {wrap(errors)?.map((error, index) => ( 14 + <ThemedError key={`${error}-${index}`}> 15 + {error instanceof Error ? error.message : error} 16 + </ThemedError> 17 + ))} 18 + </ThemedView> 19 + ) : null; 20 + }; 21 + 22 + const styles = StyleSheet.create({ 23 + container: { 24 + padding: 8, 25 + }, 26 + });
+27
app/components/theme/themed-error.tsx
···
··· 1 + import { StyleSheet } from "react-native"; 2 + import { ThemedText } from "./themed-text"; 3 + import { useThemeColor } from "@/hooks/use-theme-color"; 4 + 5 + interface ThemedErrorProps { 6 + children?: string | null; 7 + } 8 + 9 + export const ThemedError = ({ children }: ThemedErrorProps) => { 10 + const textColor = useThemeColor({}, "red"); 11 + 12 + return children ? ( 13 + <ThemedText 14 + type="small" 15 + style={styles.text} 16 + lightColor={textColor} 17 + darkColor={textColor} 18 + > 19 + {children} 20 + </ThemedText> 21 + ) : null; 22 + }; 23 + 24 + const styles = StyleSheet.create({ 25 + container: {}, 26 + text: {}, 27 + });
+62
app/components/theme/themed-text-input.tsx
···
··· 1 + import { StyleSheet, TextInput } from "react-native"; 2 + import { ThemedView } from "./themed-view"; 3 + import { ThemedErrorBox } from "./themed-error-box"; 4 + import { useThemeColor } from "@/hooks/use-theme-color"; 5 + 6 + interface ThemedTextInputProps { 7 + // see https://reactnative.dev/docs/textinput#autocomplete 8 + autoComplete?: "current-password" | "email"; 9 + // see https://reactnative.dev/docs/textinput#enterKeyHint 10 + nextHint?: "next" | "done" | "search" | "go"; 11 + onChange: (value: string) => void | Promise<void>; 12 + placeholder: string; 13 + value?: string; 14 + disabled?: boolean; 15 + type?: "text" | "password"; 16 + error?: string | string[]; 17 + } 18 + 19 + export const ThemedTextInput = ({ 20 + disabled, 21 + nextHint, 22 + onChange, 23 + placeholder, 24 + type, 25 + value, 26 + error, 27 + }: ThemedTextInputProps) => { 28 + const borderColor = useThemeColor({}, error?.length ? "red" : "text"); 29 + 30 + const textColor = useThemeColor({}, "text"); 31 + 32 + return ( 33 + <ThemedView style={[styles.inputContainer]}> 34 + <TextInput 35 + autoCapitalize="none" 36 + enterKeyHint={nextHint} 37 + onChangeText={onChange} 38 + placeholder={placeholder} 39 + placeholderTextColor="#888" 40 + style={[styles.input, { color: textColor, borderColor }]} 41 + value={value} 42 + readOnly={disabled} 43 + aria-disabled={disabled} 44 + secureTextEntry={type === "password"} 45 + /> 46 + <ThemedErrorBox errors={error} /> 47 + </ThemedView> 48 + ); 49 + }; 50 + 51 + const styles = StyleSheet.create({ 52 + input: { 53 + borderRadius: 8, 54 + padding: 8, 55 + borderWidth: 1, 56 + }, 57 + inputContainer: { 58 + borderRadius: 8, 59 + padding: 4, 60 + marginVertical: 4, 61 + }, 62 + });
+11 -2
app/components/themed-text.tsx app/components/theme/themed-text.tsx
··· 2 3 import { useThemeColor } from '@/hooks/use-theme-color'; 4 5 export type ThemedTextProps = TextProps & { 6 lightColor?: string; 7 darkColor?: string; 8 - type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; 9 }; 10 11 export function ThemedText({ 12 style, 13 lightColor, 14 darkColor, 15 type = 'default', 16 ...rest 17 }: ThemedTextProps) { 18 - const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); 19 20 return ( 21 <Text ··· 26 type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined, 27 type === 'subtitle' ? styles.subtitle : undefined, 28 type === 'link' ? styles.link : undefined, 29 style, 30 ]} 31 {...rest} ··· 37 default: { 38 fontSize: 16, 39 lineHeight: 24, 40 }, 41 defaultSemiBold: { 42 fontSize: 16,
··· 2 3 import { useThemeColor } from '@/hooks/use-theme-color'; 4 5 + export type ThemeColor = 'text' | 'red' | 'green' 6 + 7 export type ThemedTextProps = TextProps & { 8 + color?: ThemeColor; 9 lightColor?: string; 10 darkColor?: string; 11 + type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link' | 'small'; 12 }; 13 14 export function ThemedText({ 15 style, 16 + color: textColor = 'text', 17 lightColor, 18 darkColor, 19 type = 'default', 20 ...rest 21 }: ThemedTextProps) { 22 + const color = useThemeColor({ light: lightColor, dark: darkColor }, textColor); 23 24 return ( 25 <Text ··· 30 type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined, 31 type === 'subtitle' ? styles.subtitle : undefined, 32 type === 'link' ? styles.link : undefined, 33 + type === 'small' ? styles.small: undefined, 34 style, 35 ]} 36 {...rest} ··· 42 default: { 43 fontSize: 16, 44 lineHeight: 24, 45 + }, 46 + small: { 47 + fontSize: 12, 48 + lineHeight: 18, 49 }, 50 defaultSemiBold: { 51 fontSize: 16,
app/components/themed-view.tsx app/components/theme/themed-view.tsx
+63
app/components/transaction/transaction-card.tsx
···
··· 1 + import { StyleSheet, TouchableOpacity } from "react-native"; 2 + 3 + import { ThemedView, ThemedText } from "@/components/theme"; 4 + import { Price } from "@/components/ui/price"; 5 + import { Transaction } from "@/generated"; 6 + import Ionicons from "@expo/vector-icons/Ionicons"; 7 + import { mapTransactionTypeToIcon } from "@/services/utils"; 8 + 9 + interface TransactionCardProps { 10 + transaction: Transaction; 11 + onPress?: (transaction: Transaction) => Promise<void> | void; 12 + } 13 + 14 + export const TransactionCard = ({ 15 + onPress, 16 + transaction, 17 + }: TransactionCardProps) => { 18 + const { description, amount, date, type } = transaction; 19 + 20 + return ( 21 + <TouchableOpacity onPressOut={() => onPress?.(transaction)}> 22 + <ThemedView style={styles.transactionContainer}> 23 + <ThemedView style={[styles.transactionDetail]}> 24 + <Ionicons size={20} name={mapTransactionTypeToIcon(type)} /> 25 + </ThemedView> 26 + <ThemedView 27 + style={[styles.transactionDetail, styles.descriptionDetail]} 28 + > 29 + <ThemedText>{description}</ThemedText> 30 + <ThemedText type="small">{type}</ThemedText> 31 + </ThemedView> 32 + <ThemedView style={[styles.transactionDetail, styles.priceDetail]}> 33 + <Price>{amount}</Price> 34 + <ThemedText type="small"> 35 + {new Date(date).toLocaleDateString()} 36 + </ThemedText> 37 + </ThemedView> 38 + </ThemedView> 39 + </TouchableOpacity> 40 + ); 41 + }; 42 + 43 + const styles = StyleSheet.create({ 44 + transactionContainer: { 45 + display: "flex", 46 + flexDirection: "row", 47 + justifyContent: "space-between", 48 + paddingVertical: 4, 49 + gap: 4, 50 + }, 51 + transactionDetail: { 52 + flexDirection: "column", 53 + justifyContent: "center", 54 + paddingVertical: 2, 55 + padding: 4, 56 + }, 57 + descriptionDetail: { 58 + flexGrow: 1, 59 + }, 60 + priceDetail: { 61 + alignItems: "flex-end", 62 + }, 63 + });
+141
app/components/transaction/transaction-filter.tsx
···
··· 1 + import { useState } from "react"; 2 + import { StyleSheet, TouchableOpacity } from "react-native"; 3 + import { useQuery } from "@tanstack/react-query"; 4 + 5 + import { 6 + ThemedText, 7 + ThemedButton, 8 + ThemedView, 9 + ThemedTextInput, 10 + } from "@/components/theme"; 11 + import { TransactionFilterOptions } from "@/hooks/use-transactions"; 12 + import { useThemeColor } from "@/hooks/use-theme-color"; 13 + import { useAccount } from "@/hooks/use-account"; 14 + import { getTransactionTypesOptions } from "@/generated/@tanstack/react-query.gen"; 15 + import Ionicons from "@expo/vector-icons/Ionicons"; 16 + import { mapTransactionTypeToIcon } from "@/services/utils"; 17 + 18 + interface TransactionFilterProps { 19 + accountId?: number; 20 + initialState?: TransactionFilterOptions; 21 + onCancel?: () => void | Promise<void>; 22 + onSubmit?: (filter: TransactionFilterOptions) => void | Promise<void>; 23 + } 24 + 25 + const sortDirections = ["asc", "desc"] as const; 26 + 27 + export const TransactionFilter = ({ 28 + accountId, 29 + initialState = {}, 30 + onCancel, 31 + onSubmit, 32 + }: TransactionFilterProps) => { 33 + const color = useThemeColor({}, "text"); 34 + const [filter, setFilter] = useState<TransactionFilterOptions>(initialState); 35 + const { account } = useAccount(accountId ?? 0); 36 + const { data: transactionTypes } = useQuery(getTransactionTypesOptions()); 37 + 38 + return ( 39 + <ThemedView style={[styles.filterContainer, { borderColor: color }]}> 40 + <ThemedView> 41 + <ThemedText>filter transactions for </ThemedText> 42 + <ThemedText type="defaultSemiBold">{account?.name}</ThemedText> 43 + </ThemedView> 44 + <ThemedView> 45 + <ThemedText type="subtitle">Search by description</ThemedText> 46 + <ThemedTextInput 47 + value={filter.search} 48 + placeholder="search" 49 + onChange={(search) => 50 + setFilter((current: TransactionFilterOptions) => ({ 51 + ...current, 52 + search, 53 + })) 54 + } 55 + /> 56 + </ThemedView> 57 + <ThemedView> 58 + <ThemedText type="subtitle">Filter by transaction type</ThemedText> 59 + {transactionTypes?.map(({ name, count }) => ( 60 + <TouchableOpacity 61 + key={name} 62 + onPress={() => setFilter((current) => ({ ...current, type: name }))} 63 + > 64 + <ThemedView 65 + style={{ 66 + display: "flex", 67 + flexDirection: "row", 68 + alignItems: "center", 69 + gap: 4, 70 + }} 71 + > 72 + <ThemedView> 73 + <Ionicons name={mapTransactionTypeToIcon(name)} /> 74 + </ThemedView> 75 + <ThemedView> 76 + <ThemedText 77 + type={filter.type === name ? "defaultSemiBold" : "default"} 78 + > 79 + {name} ({count}) 80 + </ThemedText> 81 + </ThemedView> 82 + </ThemedView> 83 + </TouchableOpacity> 84 + ))} 85 + </ThemedView> 86 + <ThemedView> 87 + <ThemedText type="subtitle">Sort by</ThemedText> 88 + {["amount", "date"]?.map((sort) => ( 89 + <TouchableOpacity 90 + key={sort} 91 + onPress={() => setFilter((current) => ({ ...current, sort }))} 92 + > 93 + <ThemedView> 94 + <ThemedText 95 + type={filter.sort === sort ? "defaultSemiBold" : "default"} 96 + > 97 + {sort} 98 + </ThemedText> 99 + </ThemedView> 100 + </TouchableOpacity> 101 + ))} 102 + </ThemedView> 103 + <ThemedView> 104 + <ThemedText type="subtitle">Sort direction</ThemedText> 105 + {sortDirections?.map((order) => ( 106 + <TouchableOpacity 107 + key={order} 108 + onPress={() => setFilter((current) => ({ ...current, order }))} 109 + > 110 + <ThemedView> 111 + <ThemedText 112 + type={filter.order === order ? "defaultSemiBold" : "default"} 113 + > 114 + {order} 115 + </ThemedText> 116 + </ThemedView> 117 + </TouchableOpacity> 118 + ))} 119 + </ThemedView> 120 + 121 + <ThemedView style={styles.buttonGroup}> 122 + <ThemedButton onPress={() => onSubmit?.(filter)}>filter</ThemedButton> 123 + <ThemedButton onPress={() => onSubmit?.({})}>clear</ThemedButton> 124 + <ThemedButton onPress={() => onCancel?.()}>cancel</ThemedButton> 125 + </ThemedView> 126 + </ThemedView> 127 + ); 128 + }; 129 + 130 + const styles = StyleSheet.create({ 131 + buttonGroup: { 132 + display: "flex", 133 + flexDirection: "row", 134 + justifyContent: "space-around", 135 + }, 136 + filterContainer: { 137 + borderRadius: 8, 138 + borderWidth: 1, 139 + padding: 8, 140 + }, 141 + });
+55
app/components/transaction/transaction-list.tsx
···
··· 1 + import { FlashList } from "@shopify/flash-list"; 2 + import { RefreshControl, TouchableOpacity } from "react-native"; 3 + import { ThemedText } from "@/components/theme"; 4 + 5 + import { TransactionCard } from "./transaction-card"; 6 + 7 + import { 8 + useTransactions, 9 + TransactionFilterOptions, 10 + } from "@/hooks/use-transactions"; 11 + 12 + interface TransactionListProps { 13 + accountId?: number; 14 + filter?: TransactionFilterOptions; 15 + } 16 + 17 + export const TransactionList = ({ 18 + accountId, 19 + filter, 20 + }: TransactionListProps) => { 21 + const { transactions, isRefetching, refetch, hasNextPage, fetchNextPage } = 22 + useTransactions(accountId, filter); 23 + 24 + return ( 25 + <FlashList 26 + nestedScrollEnabled={true} 27 + data={(transactions?.pages ?? []).flatMap(({ data }) => data)} 28 + keyExtractor={({ id }) => id.toString()} 29 + refreshControl={ 30 + <RefreshControl 31 + tintColor="blue" 32 + refreshing={isRefetching} 33 + onRefresh={refetch} 34 + /> 35 + } 36 + ListFooterComponent={ 37 + hasNextPage ? ( 38 + <TouchableOpacity 39 + onPress={() => fetchNextPage} 40 + style={{ display: "flex" }} 41 + > 42 + <ThemedText>fetch more</ThemedText> 43 + </TouchableOpacity> 44 + ) : null 45 + } 46 + renderItem={({ item }) => ( 47 + <TransactionCard key={`${item.id}`} transaction={item} /> 48 + )} 49 + onEndReachedThreshold={0.95} 50 + onEndReached={() => { 51 + fetchNextPage(); 52 + }} 53 + /> 54 + ); 55 + };
+4 -3
app/components/ui/accountnumber.tsx
··· 1 import { TouchableOpacity } from "react-native"; 2 - import { ThemedText } from "../themed-text"; 3 import { useToggle } from "@/hooks/use-toggle"; 4 import { blurAccountNumber } from "@/services/blur-accountnumber"; 5 import { PropsWithChildren } from "react"; 6 - import { ThemedView } from "../themed-view"; 7 8 interface AccountNumberProps { 9 children: string; 10 hidden?: boolean; 11 } 12 13 export const AccountNumber = ({ 14 children, 15 hidden = false, 16 }: AccountNumberProps) => { 17 const { isHidden, toggleHidden } = useToggle(true); 18 19 return ( 20 <Wrapper onPress={toggleHidden} toggleable={!hidden}> 21 - <ThemedText> 22 {isHidden ? blurAccountNumber(children.toString()) : children} 23 </ThemedText> 24 </Wrapper>
··· 1 import { TouchableOpacity } from "react-native"; 2 + import { ThemedText, ThemedView } from "@/components/theme"; 3 import { useToggle } from "@/hooks/use-toggle"; 4 import { blurAccountNumber } from "@/services/blur-accountnumber"; 5 import { PropsWithChildren } from "react"; 6 7 interface AccountNumberProps { 8 children: string; 9 hidden?: boolean; 10 + size?: "default" | "small"; 11 } 12 13 export const AccountNumber = ({ 14 children, 15 hidden = false, 16 + size = "default", 17 }: AccountNumberProps) => { 18 const { isHidden, toggleHidden } = useToggle(true); 19 20 return ( 21 <Wrapper onPress={toggleHidden} toggleable={!hidden}> 22 + <ThemedText type={size}> 23 {isHidden ? blurAccountNumber(children.toString()) : children} 24 </ThemedText> 25 </Wrapper>
+17 -14
app/components/ui/collapsible.tsx
··· 1 - import { PropsWithChildren, useState } from 'react'; 2 - import { StyleSheet, TouchableOpacity } from 'react-native'; 3 4 - import { ThemedText } from '@/components/themed-text'; 5 - import { ThemedView } from '@/components/themed-view'; 6 - import { IconSymbol } from '@/components/ui/icon-symbol'; 7 - import { Colors } from '@/constants/theme'; 8 - import { useColorScheme } from '@/hooks/use-color-scheme'; 9 10 - export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { 11 const [isOpen, setIsOpen] = useState(false); 12 - const theme = useColorScheme() ?? 'light'; 13 14 return ( 15 <ThemedView> 16 <TouchableOpacity 17 style={styles.heading} 18 onPress={() => setIsOpen((value) => !value)} 19 - activeOpacity={0.8}> 20 <IconSymbol 21 name="chevron.right" 22 size={18} 23 weight="medium" 24 - color={theme === 'light' ? Colors.light.icon : Colors.dark.icon} 25 - style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }} 26 /> 27 28 <ThemedText type="defaultSemiBold">{title}</ThemedText> ··· 34 35 const styles = StyleSheet.create({ 36 heading: { 37 - flexDirection: 'row', 38 - alignItems: 'center', 39 gap: 6, 40 }, 41 content: {
··· 1 + import { PropsWithChildren, useState } from "react"; 2 + import { StyleSheet, TouchableOpacity } from "react-native"; 3 4 + import { ThemedText, ThemedView } from "@/components/theme"; 5 + import { IconSymbol } from "@/components/ui/icon-symbol"; 6 + import { Colors } from "@/constants/theme"; 7 + import { useColorScheme } from "@/hooks/use-color-scheme"; 8 9 + export function Collapsible({ 10 + children, 11 + title, 12 + }: PropsWithChildren & { title: string }) { 13 const [isOpen, setIsOpen] = useState(false); 14 + const theme = useColorScheme() ?? "light"; 15 16 return ( 17 <ThemedView> 18 <TouchableOpacity 19 style={styles.heading} 20 onPress={() => setIsOpen((value) => !value)} 21 + activeOpacity={0.8} 22 + > 23 <IconSymbol 24 name="chevron.right" 25 size={18} 26 weight="medium" 27 + color={theme === "light" ? Colors.light.icon : Colors.dark.icon} 28 + style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }} 29 /> 30 31 <ThemedText type="defaultSemiBold">{title}</ThemedText> ··· 37 38 const styles = StyleSheet.create({ 39 heading: { 40 + flexDirection: "row", 41 + alignItems: "center", 42 gap: 6, 43 }, 44 content: {
+2
app/components/ui/icon-symbol.tsx
··· 18 'paperplane.fill': 'send', 19 'chevron.left.forwardslash.chevron.right': 'code', 20 'chevron.right': 'chevron-right', 21 } as IconMapping; 22 23 /**
··· 18 'paperplane.fill': 'send', 19 'chevron.left.forwardslash.chevron.right': 'code', 20 'chevron.right': 'chevron-right', 21 + 'shield.fill': 'code' 22 + 23 } as IconMapping; 24 25 /**
+3 -2
app/components/ui/price.tsx
··· 1 - import { ThemedText } from "../themed-text"; 2 3 interface PriceProps { 4 children: number; ··· 7 8 export const Price = ({ children, currency = "€" }: PriceProps) => ( 9 <ThemedText> 10 {currency} 11 - {children.toFixed(2)} 12 </ThemedText> 13 );
··· 1 + import { ThemedText } from "@/components/theme"; 2 3 interface PriceProps { 4 children: number; ··· 7 8 export const Price = ({ children, currency = "€" }: PriceProps) => ( 9 <ThemedText> 10 + {children < 0 ? "- " : ""} 11 {currency} 12 + {children.toFixed(2).replaceAll("-", "")} 13 </ThemedText> 14 );
+14 -10
app/constants/theme.ts
··· 1 import { Platform } from "react-native"; 2 3 - const tintColorLight = "#0a7ea4"; 4 - const tintColorDark = "#fff"; 5 6 export const Colors = { 7 light: { 8 - text: "#11181C", 9 - background: "#fff", 10 tint: tintColorLight, 11 - icon: "#687076", 12 - tabIconDefault: "#687076", 13 tabIconSelected: tintColorLight, 14 }, 15 dark: { 16 - text: "#ECEDEE", 17 - background: "#151718", 18 tint: tintColorDark, 19 - icon: "#9BA1A6", 20 - tabIconDefault: "#9BA1A6", 21 tabIconSelected: tintColorDark, 22 }, 23 }; 24
··· 1 import { Platform } from "react-native"; 2 3 + const tintColorLight = "#1E66F5"; 4 + const tintColorDark = "#89B4FA"; 5 6 export const Colors = { 7 light: { 8 + text: "#4C4F69", 9 + background: "#EFF1F5", 10 tint: tintColorLight, 11 + icon: "#9CA0B0", 12 + tabIconDefault: "#9CA0B0", 13 tabIconSelected: tintColorLight, 14 + red: "#D20F39", 15 + green: "#40A02B", 16 }, 17 dark: { 18 + text: "#CDD6F4", 19 + background: "#1E1E2E", 20 tint: tintColorDark, 21 + icon: "#6C7086", 22 + tabIconDefault: "#6C7086", 23 tabIconSelected: tintColorDark, 24 + red: "#F38BA8", 25 + green: "#A6E3A1", 26 }, 27 }; 28
+14
app/hooks/use-account.ts
···
··· 1 + import { getAccountsByAccountIdOptions } from "@/generated/@tanstack/react-query.gen"; 2 + import { useQuery } from "@tanstack/react-query"; 3 + 4 + export const useAccount = (accountId: number) => { 5 + const { data } = useQuery( 6 + getAccountsByAccountIdOptions({ 7 + path: { accountId }, 8 + }), 9 + ); 10 + 11 + return { 12 + account: data, 13 + }; 14 + };
+1 -1
app/hooks/use-authentication.ts
··· 12 return; 13 } 14 15 - router.replace("/(auth)"); 16 }, [token, router]), 17 ); 18 };
··· 12 return; 13 } 14 15 + router.push("/(auth)"); 16 }, [token, router]), 17 ); 18 };
+72 -12
app/hooks/use-login.ts
··· 1 - import { useRouter } from "expo-router"; 2 import { useMutation } from "@tanstack/react-query"; 3 - import { LoginRequest, postLogin } from "@/generated"; 4 import { useToken } from "@/providers/token-provider"; 5 - import { useEffect } from "react"; 6 7 export const useLogin = () => { 8 - const { token, setToken } = useToken(); 9 const router = useRouter(); 10 - const { mutateAsync } = useMutation({ 11 - mutationFn: (credentials: LoginRequest) => postLogin({ body: credentials }), 12 }); 13 14 useEffect(() => { ··· 17 } 18 }, [token, router]); 19 20 - return { 21 - login: (credentials: LoginRequest) => 22 - mutateAsync(credentials).then(({ data }) => { 23 - if (data) { 24 - setToken(data); 25 } 26 - }), 27 }; 28 };
··· 1 + import { useState, useEffect, useCallback } from "react"; 2 import { useMutation } from "@tanstack/react-query"; 3 + import { LoginRequest, LoginError, postLogin } from "@/generated"; 4 + import { z } from "zod"; 5 import { useToken } from "@/providers/token-provider"; 6 + import { useRouter } from "expo-router"; 7 + 8 + const FieldErrorSchema = z.object({ 9 + errors: z.array(z.string()), 10 + }); 11 + 12 + const LoginErrorSchema = z.object({ 13 + errors: z.array(z.string()), 14 + properties: z 15 + .object({ 16 + username: FieldErrorSchema.optional(), 17 + password: FieldErrorSchema.optional(), 18 + }) 19 + .optional(), 20 + }); 21 + 22 + const isLoginError = (e: unknown): e is LoginError => { 23 + const { success } = LoginErrorSchema.safeParse(e); 24 + return success; 25 + }; 26 + 27 + const fromError = (error: unknown): LoginError => ({ 28 + errors: [error instanceof Error ? error.message : "unknown error"], 29 + properties: {}, 30 + }); 31 32 export const useLogin = () => { 33 + const { setToken, token } = useToken(); 34 const router = useRouter(); 35 + const [error, setError] = useState<null | LoginError>(null); 36 + 37 + const { 38 + isPending, 39 + mutateAsync, 40 + error: mutationError, 41 + data, 42 + } = useMutation({ 43 + mutationFn: (body: LoginRequest) => { 44 + setError(null); 45 + 46 + return postLogin({ body }); 47 + }, 48 }); 49 50 useEffect(() => { ··· 53 } 54 }, [token, router]); 55 56 + useEffect(() => { 57 + if (mutationError) { 58 + setError(fromError(mutationError)); 59 + } 60 + }, [mutationError, setError]); 61 + 62 + const login = useCallback( 63 + async (request: LoginRequest) => { 64 + try { 65 + const { data, error } = await mutateAsync(request); 66 + 67 + const realError = data && "error" in data ? data.error : error; 68 + 69 + if (realError || !data) { 70 + throw realError ?? "no data"; 71 } 72 + 73 + setToken(data); 74 + router.replace("/(tabs)"); 75 + } catch (e) { 76 + setError(isLoginError(e) ? e : fromError(e)); 77 + } 78 + }, 79 + [mutateAsync, setToken, router], 80 + ); 81 + 82 + return { 83 + data, 84 + error, 85 + loading: isPending, 86 + login, 87 }; 88 };
+7
app/hooks/use-rate-color.ts
···
··· 1 + import { useThemeColor } from "./use-theme-color"; 2 + 3 + export const useRateColor = (value: number): string => 4 + useThemeColor( 5 + {}, 6 + Number.isNaN(value) || value === 0 ? "text" : value > 0 ? "green" : "red", 7 + );
+5 -9
app/hooks/use-theme-color.ts
··· 3 * https://docs.expo.dev/guides/color-schemes/ 4 */ 5 6 - import { Colors } from '@/constants/theme'; 7 - import { useColorScheme } from '@/hooks/use-color-scheme'; 8 9 export function useThemeColor( 10 props: { light?: string; dark?: string }, 11 - colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 ) { 13 - const theme = useColorScheme() ?? 'light'; 14 const colorFromProps = props[theme]; 15 16 - if (colorFromProps) { 17 - return colorFromProps; 18 - } else { 19 - return Colors[theme][colorName]; 20 - } 21 }
··· 3 * https://docs.expo.dev/guides/color-schemes/ 4 */ 5 6 + import { Colors } from "@/constants/theme"; 7 + import { useColorScheme } from "@/hooks/use-color-scheme"; 8 9 export function useThemeColor( 10 props: { light?: string; dark?: string }, 11 + colorName: keyof typeof Colors.light & keyof typeof Colors.dark, 12 ) { 13 + const theme = useColorScheme() ?? "light"; 14 const colorFromProps = props[theme]; 15 16 + return colorFromProps || Colors[theme][colorName]; 17 }
+35
app/hooks/use-transactions.ts
···
··· 1 + import { useInfiniteQuery } from "@tanstack/react-query"; 2 + import { getTransactionsInfiniteOptions } from "@/generated/@tanstack/react-query.gen"; 3 + import { GetTransactionsData } from "@/generated"; 4 + 5 + export type TransactionFilterOptions = Omit< 6 + Exclude<GetTransactionsData["query"], undefined>, 7 + "accountId" | "page" | "limit" 8 + >; 9 + 10 + export const useTransactions = ( 11 + accountId?: number, 12 + filter: TransactionFilterOptions = {}, 13 + ) => { 14 + const { 15 + data: transactions, 16 + fetchNextPage, 17 + hasNextPage, 18 + isRefetching, 19 + refetch, 20 + } = useInfiniteQuery({ 21 + ...getTransactionsInfiniteOptions({ 22 + query: { accountId, ...filter }, 23 + }), 24 + getNextPageParam: ({ meta: { hasMore, page } }) => 25 + hasMore ? page + 1 : undefined, 26 + }); 27 + 28 + return { 29 + fetchNextPage, 30 + hasNextPage, 31 + isRefetching, 32 + refetch, 33 + transactions, 34 + }; 35 + };
+12
app/package-lock.json
··· 14 "@react-navigation/bottom-tabs": "^7.4.0", 15 "@react-navigation/elements": "^2.6.3", 16 "@react-navigation/native": "^7.1.8", 17 "@tanstack/react-query": "^5.90.2", 18 "expo": "~54.0.10", 19 "expo-constants": "~18.0.9", ··· 3679 "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", 3680 "dev": true, 3681 "license": "MIT" 3682 }, 3683 "node_modules/@sinclair/typebox": { 3684 "version": "0.27.8",
··· 14 "@react-navigation/bottom-tabs": "^7.4.0", 15 "@react-navigation/elements": "^2.6.3", 16 "@react-navigation/native": "^7.1.8", 17 + "@shopify/flash-list": "^2.1.0", 18 "@tanstack/react-query": "^5.90.2", 19 "expo": "~54.0.10", 20 "expo-constants": "~18.0.9", ··· 3680 "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", 3681 "dev": true, 3682 "license": "MIT" 3683 + }, 3684 + "node_modules/@shopify/flash-list": { 3685 + "version": "2.1.0", 3686 + "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.1.0.tgz", 3687 + "integrity": "sha512-/EIQlptG456yM5o9qNmNsmaZEFEOGvG3WGyb6GUAxSLlcKUGlPUkPI2NLW5wQSDEY4xSRa5zocUI+9xwmsM4Kg==", 3688 + "license": "MIT", 3689 + "peerDependencies": { 3690 + "@babel/runtime": "*", 3691 + "react": "*", 3692 + "react-native": "*" 3693 + } 3694 }, 3695 "node_modules/@sinclair/typebox": { 3696 "version": "0.27.8",
+1
app/package.json
··· 17 "@react-navigation/bottom-tabs": "^7.4.0", 18 "@react-navigation/elements": "^2.6.3", 19 "@react-navigation/native": "^7.1.8", 20 "@tanstack/react-query": "^5.90.2", 21 "expo": "~54.0.10", 22 "expo-constants": "~18.0.9",
··· 17 "@react-navigation/bottom-tabs": "^7.4.0", 18 "@react-navigation/elements": "^2.6.3", 19 "@react-navigation/native": "^7.1.8", 20 + "@shopify/flash-list": "^2.1.0", 21 "@tanstack/react-query": "^5.90.2", 22 "expo": "~54.0.10", 23 "expo-constants": "~18.0.9",
-20
app/providers/auth-provider.tsx
··· 1 - import { postLogin } from "@/generated"; 2 - import { useQuery, } from "@tanstack/react-query"; 3 - import { createContext, PropsWithChildren } from "react" 4 - 5 - const AuthContext = createContext(null); 6 - 7 - type AuthProviderProps = PropsWithChildren 8 - 9 - export const AuthProvider = ({ children }: AuthProviderProps) => { 10 - 11 - const { data } = useQuery({ 12 - queryKey: ['auth'], 13 - queryFn: () => postLogin({ body: { username: 'test@test.test', password: 'test@123' } }), 14 - staleTime: 1000 * 60 * 5, // 5 minutes 15 - }) 16 - 17 - console.log(data) 18 - 19 - return <AuthContext.Provider value={null}>{children}</AuthContext.Provider> 20 - }
···
+4 -7
app/providers/query-client-provider.tsx
··· 15 16 type MiddlewareParams = { 17 token: TokenPair; 18 - onRefresh: (token: TokenPair | null) => void; 19 }; 20 21 const createMiddleware = 22 - ({ token, onRefresh }: MiddlewareParams) => 23 async (request: Request) => { 24 - const freshToken = await refreshToken({ token, onRefresh }); 25 26 if (freshToken) { 27 // TODO: it would be nice to have an AbortController here ··· 32 }; 33 34 export const QueryClientProvider = ({ children }: PropsWithChildren) => { 35 - const { token, setToken } = useToken(); 36 37 useEffect(() => { 38 if (!token) { 39 return; 40 } 41 42 - const id = client.interceptors.request.use( 43 - createMiddleware({ token, onRefresh: setToken }), 44 - ); 45 46 return () => client.interceptors.request.eject(id); 47 });
··· 15 16 type MiddlewareParams = { 17 token: TokenPair; 18 }; 19 20 const createMiddleware = 21 + ({ token }: MiddlewareParams) => 22 async (request: Request) => { 23 + const freshToken = await refreshToken({ token }); 24 25 if (freshToken) { 26 // TODO: it would be nice to have an AbortController here ··· 31 }; 32 33 export const QueryClientProvider = ({ children }: PropsWithChildren) => { 34 + const { token } = useToken(); 35 36 useEffect(() => { 37 if (!token) { 38 return; 39 } 40 41 + const id = client.interceptors.request.use(createMiddleware({ token })); 42 43 return () => client.interceptors.request.eject(id); 44 });
+5 -3
app/providers/token-provider.tsx
··· 50 (async () => { 51 const t = await SecureStore.getItemAsync(tokenSymbol); 52 const state = t ? tokenSchema.parse(JSON.parse(t)) : null; 53 - const fresh = state 54 - ? await refreshToken({ token: state, onRefresh: setToken }) 55 - : null; 56 57 setTokenState(fresh); 58 setReady(true);
··· 50 (async () => { 51 const t = await SecureStore.getItemAsync(tokenSymbol); 52 const state = t ? tokenSchema.parse(JSON.parse(t)) : null; 53 + const fresh = state ? await refreshToken({ token: state }) : null; 54 + 55 + if (fresh?.accessToken !== state?.accessToken) { 56 + setToken(fresh); 57 + } 58 59 setTokenState(fresh); 60 setReady(true);
-3
app/services/token-service.ts
··· 4 5 interface TokenServiceParams { 6 token: TokenPair; 7 - onRefresh: (token: MaybeToken) => void | Promise<void>; 8 } 9 10 export const refreshToken = async ({ 11 token, 12 - onRefresh, 13 }: TokenServiceParams): Promise<MaybeToken> => { 14 const { expires, refreshToken } = token; 15 ··· 19 20 const refreshed = await postRefreshToken({ body: { refreshToken } }); 21 const newToken = refreshed?.data ?? null; 22 - onRefresh(newToken); 23 24 return newToken; 25 };
··· 4 5 interface TokenServiceParams { 6 token: TokenPair; 7 } 8 9 export const refreshToken = async ({ 10 token, 11 }: TokenServiceParams): Promise<MaybeToken> => { 12 const { expires, refreshToken } = token; 13 ··· 17 18 const refreshed = await postRefreshToken({ body: { refreshToken } }); 19 const newToken = refreshed?.data ?? null; 20 21 return newToken; 22 };
+18
app/services/utils.ts
···
··· 1 + import Ionicons from "@expo/vector-icons/Ionicons"; 2 + 3 + export type MaybeArray<T> = T | T[]; 4 + 5 + export const wrap = <T>(value: MaybeArray<T>): T[] => 6 + Array.isArray(value) ? value : [value]; 7 + 8 + type IconName = keyof (typeof Ionicons)["glyphMap"]; 9 + 10 + const mapping: Record<string, IconName> = { 11 + deposit: "bonfire-sharp", 12 + invoice: "document", 13 + payment: "compass", 14 + withdrawal: "caret-down", 15 + }; 16 + 17 + export const mapTransactionTypeToIcon = (value: string): IconName => 18 + mapping[value];
-13
package-lock.json
··· 1 - { 2 - "name": "bankingmockapi", 3 - "version": "1.0.0", 4 - "lockfileVersion": 3, 5 - "requires": true, 6 - "packages": { 7 - "": { 8 - "name": "bankingmockapi", 9 - "version": "1.0.0", 10 - "license": "ISC" 11 - } 12 - } 13 - }
···
+1 -1
server/Dockerfile
··· 8 9 COPY . . 10 11 - RUN ./node_modules/.bin/openapi --input ./src/openapi.yaml --output ./generated 12 13 USER node 14
··· 8 9 COPY . . 10 11 + RUN npm run generate 12 13 USER node 14
+374 -134
server/package-lock.json
··· 12 "bcryptjs": "2.4.3", 13 "body-parser": "^1.20.3", 14 "cors": "^2.8.5", 15 - "express": "4.21.2", 16 "express-openapi-validator": "^5.6.0", 17 "jsonwebtoken": "9.0.2", 18 "sqlite-async": "ndp/sqlite-async#13-typescript", ··· 3244 "optional": true 3245 }, 3246 "node_modules/accepts": { 3247 - "version": "1.3.8", 3248 - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", 3249 - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", 3250 "license": "MIT", 3251 "dependencies": { 3252 - "mime-types": "~2.1.34", 3253 - "negotiator": "0.6.3" 3254 }, 3255 "engines": { 3256 "node": ">= 0.6" 3257 } ··· 3470 "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 3471 "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 3472 "license": "Python-2.0" 3473 - }, 3474 - "node_modules/array-flatten": { 3475 - "version": "1.1.1", 3476 - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", 3477 - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", 3478 - "license": "MIT" 3479 }, 3480 "node_modules/asap": { 3481 "version": "2.0.6", ··· 4242 "optional": true 4243 }, 4244 "node_modules/content-disposition": { 4245 - "version": "0.5.4", 4246 - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", 4247 - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", 4248 "license": "MIT", 4249 "dependencies": { 4250 "safe-buffer": "5.2.1" ··· 4279 } 4280 }, 4281 "node_modules/cookie-signature": { 4282 - "version": "1.0.6", 4283 - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", 4284 - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", 4285 - "license": "MIT" 4286 }, 4287 "node_modules/cookiejar": { 4288 "version": "2.1.4", ··· 4795 } 4796 }, 4797 "node_modules/express": { 4798 - "version": "4.21.2", 4799 - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", 4800 - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", 4801 "license": "MIT", 4802 "dependencies": { 4803 - "accepts": "~1.3.8", 4804 - "array-flatten": "1.1.1", 4805 - "body-parser": "1.20.3", 4806 - "content-disposition": "0.5.4", 4807 - "content-type": "~1.0.4", 4808 - "cookie": "0.7.1", 4809 - "cookie-signature": "1.0.6", 4810 - "debug": "2.6.9", 4811 - "depd": "2.0.0", 4812 - "encodeurl": "~2.0.0", 4813 - "escape-html": "~1.0.3", 4814 - "etag": "~1.8.1", 4815 - "finalhandler": "1.3.1", 4816 - "fresh": "0.5.2", 4817 - "http-errors": "2.0.0", 4818 - "merge-descriptors": "1.0.3", 4819 - "methods": "~1.1.2", 4820 - "on-finished": "2.4.1", 4821 - "parseurl": "~1.3.3", 4822 - "path-to-regexp": "0.1.12", 4823 - "proxy-addr": "~2.0.7", 4824 - "qs": "6.13.0", 4825 - "range-parser": "~1.2.1", 4826 - "safe-buffer": "5.2.1", 4827 - "send": "0.19.0", 4828 - "serve-static": "1.16.2", 4829 - "setprototypeof": "1.2.0", 4830 - "statuses": "2.0.1", 4831 - "type-is": "~1.6.18", 4832 - "utils-merge": "1.0.1", 4833 - "vary": "~1.1.2" 4834 }, 4835 "engines": { 4836 - "node": ">= 0.10.0" 4837 }, 4838 "funding": { 4839 "type": "opencollective", ··· 4892 "node": ">= 0.8" 4893 } 4894 }, 4895 - "node_modules/express-openapi-validator/node_modules/path-to-regexp": { 4896 - "version": "8.3.0", 4897 - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", 4898 - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", 4899 "license": "MIT", 4900 - "funding": { 4901 - "type": "opencollective", 4902 - "url": "https://opencollective.com/express" 4903 } 4904 }, 4905 - "node_modules/express-openapi-validator/node_modules/qs": { 4906 "version": "6.14.0", 4907 "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", 4908 "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", ··· 4917 "url": "https://github.com/sponsors/ljharb" 4918 } 4919 }, 4920 "node_modules/faker": { 4921 "version": "6.6.6", 4922 "resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz", ··· 4990 } 4991 }, 4992 "node_modules/finalhandler": { 4993 - "version": "1.3.1", 4994 - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", 4995 - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", 4996 "license": "MIT", 4997 "dependencies": { 4998 - "debug": "2.6.9", 4999 - "encodeurl": "~2.0.0", 5000 - "escape-html": "~1.0.3", 5001 - "on-finished": "2.4.1", 5002 - "parseurl": "~1.3.3", 5003 - "statuses": "2.0.1", 5004 - "unpipe": "~1.0.0" 5005 }, 5006 "engines": { 5007 "node": ">= 0.8" 5008 } 5009 }, 5010 "node_modules/find-up": { 5011 "version": "4.1.0", 5012 "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", ··· 5066 } 5067 }, 5068 "node_modules/fresh": { 5069 - "version": "0.5.2", 5070 - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", 5071 - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", 5072 "license": "MIT", 5073 "engines": { 5074 - "node": ">= 0.6" 5075 } 5076 }, 5077 "node_modules/fs-constants": { ··· 5760 "engines": { 5761 "node": ">=0.12.0" 5762 } 5763 }, 5764 "node_modules/is-stream": { 5765 "version": "2.0.1", ··· 6856 } 6857 }, 6858 "node_modules/merge-descriptors": { 6859 - "version": "1.0.3", 6860 - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", 6861 - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", 6862 "license": "MIT", 6863 "funding": { 6864 "url": "https://github.com/sponsors/sindresorhus" 6865 } ··· 6875 "version": "1.1.2", 6876 "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 6877 "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 6878 "license": "MIT", 6879 "engines": { 6880 "node": ">= 0.6" ··· 6894 "node": ">=8.6" 6895 } 6896 }, 6897 - "node_modules/mime": { 6898 - "version": "1.6.0", 6899 - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", 6900 - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", 6901 - "license": "MIT", 6902 - "bin": { 6903 - "mime": "cli.js" 6904 - }, 6905 - "engines": { 6906 - "node": ">=4" 6907 - } 6908 - }, 6909 "node_modules/mime-db": { 6910 "version": "1.52.0", 6911 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", ··· 7138 "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 7139 "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 7140 "license": "MIT", 7141 "engines": { 7142 "node": ">= 0.6" 7143 } ··· 7634 "license": "MIT" 7635 }, 7636 "node_modules/path-to-regexp": { 7637 - "version": "0.1.12", 7638 - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", 7639 - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", 7640 - "license": "MIT" 7641 }, 7642 "node_modules/picocolors": { 7643 "version": "1.1.1", ··· 8083 "url": "https://github.com/sponsors/isaacs" 8084 } 8085 }, 8086 "node_modules/safe-buffer": { 8087 "version": "5.2.1", 8088 "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", ··· 8122 } 8123 }, 8124 "node_modules/send": { 8125 - "version": "0.19.0", 8126 - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", 8127 - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", 8128 "license": "MIT", 8129 "dependencies": { 8130 - "debug": "2.6.9", 8131 - "depd": "2.0.0", 8132 - "destroy": "1.2.0", 8133 - "encodeurl": "~1.0.2", 8134 - "escape-html": "~1.0.3", 8135 - "etag": "~1.8.1", 8136 - "fresh": "0.5.2", 8137 - "http-errors": "2.0.0", 8138 - "mime": "1.6.0", 8139 - "ms": "2.1.3", 8140 - "on-finished": "2.4.1", 8141 - "range-parser": "~1.2.1", 8142 - "statuses": "2.0.1" 8143 }, 8144 "engines": { 8145 - "node": ">= 0.8.0" 8146 } 8147 }, 8148 - "node_modules/send/node_modules/encodeurl": { 8149 - "version": "1.0.2", 8150 - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", 8151 - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", 8152 "license": "MIT", 8153 "engines": { 8154 - "node": ">= 0.8" 8155 } 8156 }, 8157 "node_modules/send/node_modules/ms": { ··· 8161 "license": "MIT" 8162 }, 8163 "node_modules/serve-static": { 8164 - "version": "1.16.2", 8165 - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", 8166 - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", 8167 "license": "MIT", 8168 "dependencies": { 8169 - "encodeurl": "~2.0.0", 8170 - "escape-html": "~1.0.3", 8171 - "parseurl": "~1.3.3", 8172 - "send": "0.19.0" 8173 }, 8174 "engines": { 8175 - "node": ">= 0.8.0" 8176 } 8177 }, 8178 "node_modules/set-blocking": { ··· 9139 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 9140 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 9141 "license": "MIT" 9142 - }, 9143 - "node_modules/utils-merge": { 9144 - "version": "1.0.1", 9145 - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", 9146 - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", 9147 - "license": "MIT", 9148 - "engines": { 9149 - "node": ">= 0.4.0" 9150 - } 9151 }, 9152 "node_modules/v8-compile-cache-lib": { 9153 "version": "3.0.1",
··· 12 "bcryptjs": "2.4.3", 13 "body-parser": "^1.20.3", 14 "cors": "^2.8.5", 15 + "express": "^5.1.0", 16 "express-openapi-validator": "^5.6.0", 17 "jsonwebtoken": "9.0.2", 18 "sqlite-async": "ndp/sqlite-async#13-typescript", ··· 3244 "optional": true 3245 }, 3246 "node_modules/accepts": { 3247 + "version": "2.0.0", 3248 + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", 3249 + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", 3250 "license": "MIT", 3251 "dependencies": { 3252 + "mime-types": "^3.0.0", 3253 + "negotiator": "^1.0.0" 3254 }, 3255 + "engines": { 3256 + "node": ">= 0.6" 3257 + } 3258 + }, 3259 + "node_modules/accepts/node_modules/mime-db": { 3260 + "version": "1.54.0", 3261 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", 3262 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", 3263 + "license": "MIT", 3264 + "engines": { 3265 + "node": ">= 0.6" 3266 + } 3267 + }, 3268 + "node_modules/accepts/node_modules/mime-types": { 3269 + "version": "3.0.1", 3270 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", 3271 + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", 3272 + "license": "MIT", 3273 + "dependencies": { 3274 + "mime-db": "^1.54.0" 3275 + }, 3276 + "engines": { 3277 + "node": ">= 0.6" 3278 + } 3279 + }, 3280 + "node_modules/accepts/node_modules/negotiator": { 3281 + "version": "1.0.0", 3282 + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", 3283 + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", 3284 + "license": "MIT", 3285 "engines": { 3286 "node": ">= 0.6" 3287 } ··· 3500 "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", 3501 "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", 3502 "license": "Python-2.0" 3503 }, 3504 "node_modules/asap": { 3505 "version": "2.0.6", ··· 4266 "optional": true 4267 }, 4268 "node_modules/content-disposition": { 4269 + "version": "1.0.0", 4270 + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", 4271 + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", 4272 "license": "MIT", 4273 "dependencies": { 4274 "safe-buffer": "5.2.1" ··· 4303 } 4304 }, 4305 "node_modules/cookie-signature": { 4306 + "version": "1.2.2", 4307 + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", 4308 + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", 4309 + "license": "MIT", 4310 + "engines": { 4311 + "node": ">=6.6.0" 4312 + } 4313 }, 4314 "node_modules/cookiejar": { 4315 "version": "2.1.4", ··· 4822 } 4823 }, 4824 "node_modules/express": { 4825 + "version": "5.1.0", 4826 + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", 4827 + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", 4828 "license": "MIT", 4829 "dependencies": { 4830 + "accepts": "^2.0.0", 4831 + "body-parser": "^2.2.0", 4832 + "content-disposition": "^1.0.0", 4833 + "content-type": "^1.0.5", 4834 + "cookie": "^0.7.1", 4835 + "cookie-signature": "^1.2.1", 4836 + "debug": "^4.4.0", 4837 + "encodeurl": "^2.0.0", 4838 + "escape-html": "^1.0.3", 4839 + "etag": "^1.8.1", 4840 + "finalhandler": "^2.1.0", 4841 + "fresh": "^2.0.0", 4842 + "http-errors": "^2.0.0", 4843 + "merge-descriptors": "^2.0.0", 4844 + "mime-types": "^3.0.0", 4845 + "on-finished": "^2.4.1", 4846 + "once": "^1.4.0", 4847 + "parseurl": "^1.3.3", 4848 + "proxy-addr": "^2.0.7", 4849 + "qs": "^6.14.0", 4850 + "range-parser": "^1.2.1", 4851 + "router": "^2.2.0", 4852 + "send": "^1.1.0", 4853 + "serve-static": "^2.2.0", 4854 + "statuses": "^2.0.1", 4855 + "type-is": "^2.0.1", 4856 + "vary": "^1.1.2" 4857 }, 4858 "engines": { 4859 + "node": ">= 18" 4860 }, 4861 "funding": { 4862 "type": "opencollective", ··· 4915 "node": ">= 0.8" 4916 } 4917 }, 4918 + "node_modules/express-openapi-validator/node_modules/qs": { 4919 + "version": "6.14.0", 4920 + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", 4921 + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", 4922 + "license": "BSD-3-Clause", 4923 + "dependencies": { 4924 + "side-channel": "^1.1.0" 4925 + }, 4926 + "engines": { 4927 + "node": ">=0.6" 4928 + }, 4929 + "funding": { 4930 + "url": "https://github.com/sponsors/ljharb" 4931 + } 4932 + }, 4933 + "node_modules/express/node_modules/body-parser": { 4934 + "version": "2.2.0", 4935 + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", 4936 + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", 4937 + "license": "MIT", 4938 + "dependencies": { 4939 + "bytes": "^3.1.2", 4940 + "content-type": "^1.0.5", 4941 + "debug": "^4.4.0", 4942 + "http-errors": "^2.0.0", 4943 + "iconv-lite": "^0.6.3", 4944 + "on-finished": "^2.4.1", 4945 + "qs": "^6.14.0", 4946 + "raw-body": "^3.0.0", 4947 + "type-is": "^2.0.0" 4948 + }, 4949 + "engines": { 4950 + "node": ">=18" 4951 + } 4952 + }, 4953 + "node_modules/express/node_modules/debug": { 4954 + "version": "4.4.3", 4955 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 4956 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 4957 "license": "MIT", 4958 + "dependencies": { 4959 + "ms": "^2.1.3" 4960 + }, 4961 + "engines": { 4962 + "node": ">=6.0" 4963 + }, 4964 + "peerDependenciesMeta": { 4965 + "supports-color": { 4966 + "optional": true 4967 + } 4968 } 4969 }, 4970 + "node_modules/express/node_modules/iconv-lite": { 4971 + "version": "0.6.3", 4972 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 4973 + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 4974 + "license": "MIT", 4975 + "dependencies": { 4976 + "safer-buffer": ">= 2.1.2 < 3.0.0" 4977 + }, 4978 + "engines": { 4979 + "node": ">=0.10.0" 4980 + } 4981 + }, 4982 + "node_modules/express/node_modules/media-typer": { 4983 + "version": "1.1.0", 4984 + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", 4985 + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", 4986 + "license": "MIT", 4987 + "engines": { 4988 + "node": ">= 0.8" 4989 + } 4990 + }, 4991 + "node_modules/express/node_modules/mime-db": { 4992 + "version": "1.54.0", 4993 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", 4994 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", 4995 + "license": "MIT", 4996 + "engines": { 4997 + "node": ">= 0.6" 4998 + } 4999 + }, 5000 + "node_modules/express/node_modules/mime-types": { 5001 + "version": "3.0.1", 5002 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", 5003 + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", 5004 + "license": "MIT", 5005 + "dependencies": { 5006 + "mime-db": "^1.54.0" 5007 + }, 5008 + "engines": { 5009 + "node": ">= 0.6" 5010 + } 5011 + }, 5012 + "node_modules/express/node_modules/ms": { 5013 + "version": "2.1.3", 5014 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 5015 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 5016 + "license": "MIT" 5017 + }, 5018 + "node_modules/express/node_modules/qs": { 5019 "version": "6.14.0", 5020 "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", 5021 "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", ··· 5030 "url": "https://github.com/sponsors/ljharb" 5031 } 5032 }, 5033 + "node_modules/express/node_modules/raw-body": { 5034 + "version": "3.0.1", 5035 + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", 5036 + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", 5037 + "license": "MIT", 5038 + "dependencies": { 5039 + "bytes": "3.1.2", 5040 + "http-errors": "2.0.0", 5041 + "iconv-lite": "0.7.0", 5042 + "unpipe": "1.0.0" 5043 + }, 5044 + "engines": { 5045 + "node": ">= 0.10" 5046 + } 5047 + }, 5048 + "node_modules/express/node_modules/raw-body/node_modules/iconv-lite": { 5049 + "version": "0.7.0", 5050 + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", 5051 + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", 5052 + "license": "MIT", 5053 + "dependencies": { 5054 + "safer-buffer": ">= 2.1.2 < 3.0.0" 5055 + }, 5056 + "engines": { 5057 + "node": ">=0.10.0" 5058 + }, 5059 + "funding": { 5060 + "type": "opencollective", 5061 + "url": "https://opencollective.com/express" 5062 + } 5063 + }, 5064 + "node_modules/express/node_modules/type-is": { 5065 + "version": "2.0.1", 5066 + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", 5067 + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", 5068 + "license": "MIT", 5069 + "dependencies": { 5070 + "content-type": "^1.0.5", 5071 + "media-typer": "^1.1.0", 5072 + "mime-types": "^3.0.0" 5073 + }, 5074 + "engines": { 5075 + "node": ">= 0.6" 5076 + } 5077 + }, 5078 "node_modules/faker": { 5079 "version": "6.6.6", 5080 "resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz", ··· 5148 } 5149 }, 5150 "node_modules/finalhandler": { 5151 + "version": "2.1.0", 5152 + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", 5153 + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", 5154 "license": "MIT", 5155 "dependencies": { 5156 + "debug": "^4.4.0", 5157 + "encodeurl": "^2.0.0", 5158 + "escape-html": "^1.0.3", 5159 + "on-finished": "^2.4.1", 5160 + "parseurl": "^1.3.3", 5161 + "statuses": "^2.0.1" 5162 }, 5163 "engines": { 5164 "node": ">= 0.8" 5165 } 5166 }, 5167 + "node_modules/finalhandler/node_modules/debug": { 5168 + "version": "4.4.3", 5169 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 5170 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 5171 + "license": "MIT", 5172 + "dependencies": { 5173 + "ms": "^2.1.3" 5174 + }, 5175 + "engines": { 5176 + "node": ">=6.0" 5177 + }, 5178 + "peerDependenciesMeta": { 5179 + "supports-color": { 5180 + "optional": true 5181 + } 5182 + } 5183 + }, 5184 + "node_modules/finalhandler/node_modules/ms": { 5185 + "version": "2.1.3", 5186 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 5187 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 5188 + "license": "MIT" 5189 + }, 5190 "node_modules/find-up": { 5191 "version": "4.1.0", 5192 "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", ··· 5246 } 5247 }, 5248 "node_modules/fresh": { 5249 + "version": "2.0.0", 5250 + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", 5251 + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", 5252 "license": "MIT", 5253 "engines": { 5254 + "node": ">= 0.8" 5255 } 5256 }, 5257 "node_modules/fs-constants": { ··· 5940 "engines": { 5941 "node": ">=0.12.0" 5942 } 5943 + }, 5944 + "node_modules/is-promise": { 5945 + "version": "4.0.0", 5946 + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", 5947 + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", 5948 + "license": "MIT" 5949 }, 5950 "node_modules/is-stream": { 5951 "version": "2.0.1", ··· 7042 } 7043 }, 7044 "node_modules/merge-descriptors": { 7045 + "version": "2.0.0", 7046 + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", 7047 + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", 7048 "license": "MIT", 7049 + "engines": { 7050 + "node": ">=18" 7051 + }, 7052 "funding": { 7053 "url": "https://github.com/sponsors/sindresorhus" 7054 } ··· 7064 "version": "1.1.2", 7065 "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 7066 "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", 7067 + "dev": true, 7068 "license": "MIT", 7069 "engines": { 7070 "node": ">= 0.6" ··· 7084 "node": ">=8.6" 7085 } 7086 }, 7087 "node_modules/mime-db": { 7088 "version": "1.52.0", 7089 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", ··· 7316 "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", 7317 "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", 7318 "license": "MIT", 7319 + "optional": true, 7320 "engines": { 7321 "node": ">= 0.6" 7322 } ··· 7813 "license": "MIT" 7814 }, 7815 "node_modules/path-to-regexp": { 7816 + "version": "8.3.0", 7817 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", 7818 + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", 7819 + "license": "MIT", 7820 + "funding": { 7821 + "type": "opencollective", 7822 + "url": "https://opencollective.com/express" 7823 + } 7824 }, 7825 "node_modules/picocolors": { 7826 "version": "1.1.1", ··· 8266 "url": "https://github.com/sponsors/isaacs" 8267 } 8268 }, 8269 + "node_modules/router": { 8270 + "version": "2.2.0", 8271 + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", 8272 + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", 8273 + "license": "MIT", 8274 + "dependencies": { 8275 + "debug": "^4.4.0", 8276 + "depd": "^2.0.0", 8277 + "is-promise": "^4.0.0", 8278 + "parseurl": "^1.3.3", 8279 + "path-to-regexp": "^8.0.0" 8280 + }, 8281 + "engines": { 8282 + "node": ">= 18" 8283 + } 8284 + }, 8285 + "node_modules/router/node_modules/debug": { 8286 + "version": "4.4.3", 8287 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 8288 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 8289 + "license": "MIT", 8290 + "dependencies": { 8291 + "ms": "^2.1.3" 8292 + }, 8293 + "engines": { 8294 + "node": ">=6.0" 8295 + }, 8296 + "peerDependenciesMeta": { 8297 + "supports-color": { 8298 + "optional": true 8299 + } 8300 + } 8301 + }, 8302 + "node_modules/router/node_modules/ms": { 8303 + "version": "2.1.3", 8304 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 8305 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 8306 + "license": "MIT" 8307 + }, 8308 "node_modules/safe-buffer": { 8309 "version": "5.2.1", 8310 "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", ··· 8344 } 8345 }, 8346 "node_modules/send": { 8347 + "version": "1.2.0", 8348 + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", 8349 + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", 8350 "license": "MIT", 8351 "dependencies": { 8352 + "debug": "^4.3.5", 8353 + "encodeurl": "^2.0.0", 8354 + "escape-html": "^1.0.3", 8355 + "etag": "^1.8.1", 8356 + "fresh": "^2.0.0", 8357 + "http-errors": "^2.0.0", 8358 + "mime-types": "^3.0.1", 8359 + "ms": "^2.1.3", 8360 + "on-finished": "^2.4.1", 8361 + "range-parser": "^1.2.1", 8362 + "statuses": "^2.0.1" 8363 }, 8364 "engines": { 8365 + "node": ">= 18" 8366 + } 8367 + }, 8368 + "node_modules/send/node_modules/debug": { 8369 + "version": "4.4.3", 8370 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", 8371 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", 8372 + "license": "MIT", 8373 + "dependencies": { 8374 + "ms": "^2.1.3" 8375 + }, 8376 + "engines": { 8377 + "node": ">=6.0" 8378 + }, 8379 + "peerDependenciesMeta": { 8380 + "supports-color": { 8381 + "optional": true 8382 + } 8383 + } 8384 + }, 8385 + "node_modules/send/node_modules/mime-db": { 8386 + "version": "1.54.0", 8387 + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", 8388 + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", 8389 + "license": "MIT", 8390 + "engines": { 8391 + "node": ">= 0.6" 8392 } 8393 }, 8394 + "node_modules/send/node_modules/mime-types": { 8395 + "version": "3.0.1", 8396 + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", 8397 + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", 8398 "license": "MIT", 8399 + "dependencies": { 8400 + "mime-db": "^1.54.0" 8401 + }, 8402 "engines": { 8403 + "node": ">= 0.6" 8404 } 8405 }, 8406 "node_modules/send/node_modules/ms": { ··· 8410 "license": "MIT" 8411 }, 8412 "node_modules/serve-static": { 8413 + "version": "2.2.0", 8414 + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", 8415 + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", 8416 "license": "MIT", 8417 "dependencies": { 8418 + "encodeurl": "^2.0.0", 8419 + "escape-html": "^1.0.3", 8420 + "parseurl": "^1.3.3", 8421 + "send": "^1.2.0" 8422 }, 8423 "engines": { 8424 + "node": ">= 18" 8425 } 8426 }, 8427 "node_modules/set-blocking": { ··· 9388 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 9389 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 9390 "license": "MIT" 9391 }, 9392 "node_modules/v8-compile-cache-lib": { 9393 "version": "3.0.1",
+1 -1
server/package.json
··· 14 "bcryptjs": "2.4.3", 15 "body-parser": "^1.20.3", 16 "cors": "^2.8.5", 17 - "express": "4.21.2", 18 "express-openapi-validator": "^5.6.0", 19 "jsonwebtoken": "9.0.2", 20 "sqlite-async": "ndp/sqlite-async#13-typescript",
··· 14 "bcryptjs": "2.4.3", 15 "body-parser": "^1.20.3", 16 "cors": "^2.8.5", 17 + "express": "^5.1.0", 18 "express-openapi-validator": "^5.6.0", 19 "jsonwebtoken": "9.0.2", 20 "sqlite-async": "ndp/sqlite-async#13-typescript",
+96 -7
server/src/openapi.yaml
··· 108 expiry: { type: string, example: "12/26" } 109 cvv: { type: string, example: "123" } 110 111 TransactionType: 112 type: object 113 required: [name, count] 114 properties: 115 - name: { type: string} 116 - count: { type: integer } 117 118 Transaction: 119 type: object ··· 164 message: 165 type: string 166 167 tags: 168 - name: Meta 169 - name: Auth ··· 240 content: 241 application/json: 242 schema: 243 - $ref: "#/components/schemas/Error" 244 "500": 245 $ref: "#/components/responses/Error500" 246 ··· 290 "500": 291 $ref: "#/components/responses/Error500" 292 293 /cards: 294 get: 295 summary: List stored cards for the authenticated user ··· 315 316 /transaction-types: 317 get: 318 - summary: Find transaction types for the authenticated user 319 tags: [Transactions] 320 security: 321 - BearerAuth: [] ··· 397 content: 398 application/json: 399 schema: 400 - type: array 401 - items: 402 - $ref: "#/components/schemas/Transaction" 403 "400": 404 $ref: "#/components/responses/Error400" 405 "401":
··· 108 expiry: { type: string, example: "12/26" } 109 cvv: { type: string, example: "123" } 110 111 + PaginationMeta: 112 + type: object 113 + required: [page, limit, total, hasMore] 114 + properties: 115 + page: 116 + type: integer 117 + description: Current page number (1-based) 118 + limit: 119 + type: integer 120 + description: Page size 121 + hasMore: 122 + type: boolean 123 + description: True if there are more pages after the current one 124 + total: 125 + type: integer 126 + description: Total number of matching items 127 + 128 + PaginatedTransactions: 129 + type: object 130 + required: [data, meta] 131 + properties: 132 + data: 133 + type: array 134 + items: 135 + $ref: "#/components/schemas/Transaction" 136 + meta: 137 + $ref: "#/components/schemas/PaginationMeta" 138 + 139 TransactionType: 140 type: object 141 required: [name, count] 142 properties: 143 + name: { type: string } 144 + count: { type: integer } 145 146 Transaction: 147 type: object ··· 192 message: 193 type: string 194 195 + LoginError: 196 + type: object 197 + description: Standard validation/error envelope. 198 + required: [errors] 199 + properties: 200 + errors: 201 + type: array 202 + description: Non-field/global errors. 203 + items: 204 + type: string 205 + properties: 206 + type: object 207 + description: Per-field validation errors. 208 + properties: 209 + username: 210 + $ref: "#/components/schemas/FieldError" 211 + password: 212 + $ref: "#/components/schemas/FieldError" 213 + 214 + example: 215 + errors: [] 216 + properties: 217 + username: 218 + errors: ["Invalid email address"] 219 + password: 220 + errors: ["Too small: expected string to have >=8 characters"] 221 + 222 + FieldError: 223 + type: object 224 + required: [errors] 225 + properties: 226 + errors: 227 + type: array 228 + items: 229 + type: string 230 + 231 tags: 232 - name: Meta 233 - name: Auth ··· 304 content: 305 application/json: 306 schema: 307 + $ref: "#/components/schemas/LoginError" 308 "500": 309 $ref: "#/components/responses/Error500" 310 ··· 354 "500": 355 $ref: "#/components/responses/Error500" 356 357 + /accounts/{accountId}: 358 + get: 359 + summary: Retrieves a single account for the authenticated user, including calculated balances 360 + parameters: 361 + - in: path 362 + name: accountId 363 + schema: 364 + type: integer 365 + required: true 366 + description: Numeric ID of the account to get 367 + tags: [Accounts] 368 + security: 369 + - BearerAuth: [] 370 + responses: 371 + "200": 372 + description: Account object 373 + content: 374 + application/json: 375 + schema: 376 + $ref: "#/components/schemas/Account" 377 + "401": 378 + $ref: "#/components/responses/Error401" 379 + "403": 380 + $ref: "#/components/responses/Error403" 381 + "500": 382 + $ref: "#/components/responses/Error500" 383 + 384 /cards: 385 get: 386 summary: List stored cards for the authenticated user ··· 406 407 /transaction-types: 408 get: 409 + summary: Find transaction types for the authenticated user 410 tags: [Transactions] 411 security: 412 - BearerAuth: [] ··· 488 content: 489 application/json: 490 schema: 491 + $ref: "#/components/schemas/PaginatedTransactions" 492 "400": 493 $ref: "#/components/responses/Error400" 494 "401":
+43 -6
server/src/schema.ts
··· 1 import { Request as ExpressRequest } from "express"; 2 import { z } from "zod"; 3 4 - export type Request<T extends Record<string, string> = {}> = 5 - ExpressRequest<T> & { 6 - user?: User; 7 - }; 8 9 export type User = { 10 id: number; ··· 12 fullname: string; 13 password: string; 14 created: Date; 15 }; 16 17 export const LoginSchema = z.object({ ··· 19 password: z.string().min(8), 20 }); 21 22 export const TransactionTypesQuerySchema = z.object({ 23 accountId: z.preprocess( 24 (val) => (val ? Number(val) : undefined), ··· 26 ), 27 }); 28 29 export const TransactionsQuerySchema = z.object({ 30 search: z.string().optional().default(""), 31 sort: z.string().optional().default("date"), 32 order: z.enum(["asc", "desc"]).optional().default("desc"), 33 - page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)), 34 - limit: z.preprocess((val) => Number(val ?? 25), z.int().min(1).max(100)), 35 accountId: z.preprocess( 36 (val) => (val ? Number(val) : undefined), 37 z.number().optional(), 38 ), 39 type: z.string().optional(), 40 });
··· 1 import { Request as ExpressRequest } from "express"; 2 import { z } from "zod"; 3 4 + export type Request<T extends {} = {}> = ExpressRequest<T> & { 5 + user?: User; 6 + }; 7 8 export type User = { 9 id: number; ··· 11 fullname: string; 12 password: string; 13 created: Date; 14 + }; 15 + 16 + export type Account = { 17 + id: number; 18 + name: string; 19 + user_id: number; 20 + iban: string; 21 + balance: number; 22 + }; 23 + 24 + export type Transaction = { 25 + id: number; 26 + user_id: number; 27 + account_id: number; 28 + amount: number; 29 + type: string; 30 + description: string; 31 + date: string; 32 }; 33 34 export const LoginSchema = z.object({ ··· 36 password: z.string().min(8), 37 }); 38 39 + export type TransactionType = { 40 + name: string; 41 + count: number; 42 + }; 43 + 44 export const TransactionTypesQuerySchema = z.object({ 45 accountId: z.preprocess( 46 (val) => (val ? Number(val) : undefined), ··· 48 ), 49 }); 50 51 + export type PaginationRespone<T> = { 52 + data: T[]; 53 + meta: { 54 + total: number; 55 + page: number; 56 + hasMore: boolean; 57 + }; 58 + }; 59 + 60 + export type TransactionTypesQuery = z.infer<typeof TransactionTypesQuerySchema>; 61 + 62 export const TransactionsQuerySchema = z.object({ 63 search: z.string().optional().default(""), 64 sort: z.string().optional().default("date"), 65 order: z.enum(["asc", "desc"]).optional().default("desc"), 66 + page: z.preprocess((val) => Number(val ?? 1), z.int().min(1)).default(1), 67 + limit: z 68 + .preprocess((val) => Number(val ?? 25), z.int().min(1).max(100)) 69 + .default(25), 70 accountId: z.preprocess( 71 (val) => (val ? Number(val) : undefined), 72 z.number().optional(), 73 ), 74 type: z.string().optional(), 75 }); 76 + 77 + export type TransactionsQuery = z.infer<typeof TransactionsQuerySchema>;
+18 -14
server/src/seeder.ts
··· 106 ]) 107 ).map(({ id }) => id); 108 109 - const transactions = range(100).map(() => [ 110 - userId, 111 - accountIds[Math.floor(Math.random() * accountIds.length)], 112 - faker.finance.amount({ min: -1500, max: 2500, dec: 2 }), 113 - faker.finance.transactionType(), 114 - faker.commerce.productName(), 115 - faker.date.recent({ days: 30 }).toISOString(), 116 - ]); 117 118 - // Seed transactions 119 - db.run( 120 - `INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions 121 - .map(() => "(?, ?, ?, ?, ?, ?)") 122 - .join(", ")}`, 123 - transactions.flat(), 124 ); 125 };
··· 106 ]) 107 ).map(({ id }) => id); 108 109 + await Promise.all( 110 + accountIds.map(async (accountId) => { 111 + const transactions = range(1000).map(() => [ 112 + userId, 113 + accountId, 114 + faker.finance.amount({ min: -1500, max: 2500, dec: 2 }), 115 + faker.finance.transactionType(), 116 + faker.commerce.productName(), 117 + faker.date.recent({ days: 30 }).toISOString(), 118 + ]); 119 120 + // Seed transactions 121 + db.run( 122 + `INSERT INTO transactions (user_id, account_id, amount, type, description, date) VALUES ${transactions 123 + .map(() => "(?, ?, ?, ?, ?, ?)") 124 + .join(", ")}`, 125 + transactions.flat(), 126 + ); 127 + }), 128 ); 129 };
+63 -102
server/src/server.ts
··· 11 JWT_SECRET, 12 TOKEN_EXPIRY_MINUTES, 13 } from "./config"; 14 - import { LoginRequest, TokenPair, User as UserResponse } from "../generated"; 15 import { readFileSync } from "fs"; 16 import { generateRefreshToken, generateToken, verifyToken } from "./auth"; 17 import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema"; 18 import { HttpError } from "express-openapi-validator/dist/framework/types"; 19 20 const authenticateToken = async ( 21 req: Request, ··· 48 export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => { 49 const app = express(); 50 51 app.use(json()); 52 app.use(cors<Request>({ origin: CORS_ORIGIN })); 53 ··· 69 res.send(readFileSync(specPath).toString()); 70 }); 71 72 - app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 73 - if (err instanceof ZodError) { 74 - res.status(400).json(z.treeifyError(err)); 75 - return; 76 - } 77 - 78 - if (err instanceof HttpError) { 79 - res.status(err.status).json({ message: err.message }).send(); 80 - return; 81 - } 82 - 83 - res 84 - .status(500) 85 - .json({ error: `Internal server error: ${err.message}` }) 86 - .send(); 87 - }); 88 - 89 app.post("/login", async ({ body }: Request<LoginRequest>, res: Response) => { 90 const { username, password } = LoginSchema.parse(body); 91 ··· 93 username, 94 ]); 95 96 - if (!user || !compare(password, user.password)) { 97 - return res.status(401).json({ message: "Invalid credentials" }); 98 } 99 100 const now = new Date(); ··· 107 refreshToken: generateRefreshToken(user), 108 }; 109 110 - res.json(response); 111 }); 112 113 app.post("/refresh-token", async ({ body }: Request, res: Response) => { ··· 153 created: new Date(user.created).toISOString(), 154 }; 155 156 - res.json(response); 157 }); 158 159 app.get( 160 - "/accounts", 161 authenticateToken, 162 - async ({ user }: Request, res: Response) => { 163 - const accounts = await db.all( 164 - ` 165 - SELECT accounts.*, 166 - IFNULL(t.balance, 0) as balance 167 168 - FROM accounts 169 170 - LEFT JOIN ( 171 - SELECT account_id, SUM(amount) as balance 172 - FROM transactions 173 - WHERE user_id = ? 174 - GROUP BY account_id 175 - ) AS t ON accounts.id = t.account_id 176 177 - WHERE accounts.user_id = ? 178 - `, 179 - [user.id, user.id], 180 - ); 181 - 182 - return res.json(accounts ?? []); 183 }, 184 ); 185 ··· 198 app.get( 199 "/transaction-types", 200 authenticateToken, 201 - async (request: Request, res: Response) => { 202 - const { accountId } = TransactionsQuerySchema.parse(request.query); 203 204 - let query = ` 205 - SELECT count(*) as count, 206 - type as name 207 - FROM transactions 208 - WHERE user_id = ? 209 - `; 210 - 211 - let params: Array<string | number> = [request.user.id]; 212 - 213 - if (accountId) { 214 - query += " AND (account_id = ?)"; 215 - params.push(accountId); 216 - } 217 - 218 - query += " GROUP BY type ORDER BY count DESC"; 219 - 220 - return res.json((await db.all(query, params)) ?? []); 221 }, 222 ); 223 224 app.get( 225 "/transactions", 226 authenticateToken, 227 - async (req: Request, res: Response) => { 228 - const { page, limit, search, sort, order, accountId, type } = 229 - TransactionsQuerySchema.parse(req.query); 230 231 - const offset = (page - 1) * limit; 232 - 233 - let query = "SELECT * FROM transactions WHERE user_id = ?"; 234 - 235 - let params: Array<string | number> = [req.user.id]; 236 - 237 - if (search.length) { 238 - query += " AND (description LIKE ? OR type LIKE ?)"; 239 - params.push(`%${search}%`, `%${search}%`); 240 - } 241 242 - if (accountId) { 243 - query += " AND (account_id = ?)"; 244 - params.push(accountId); 245 - } 246 247 - if (type) { 248 - query += " AND (type = ?)"; 249 - params.push(type); 250 - } 251 252 - query += ` ORDER BY ? ${order} LIMIT ? OFFSET ?`; 253 - params.push(sort, limit, offset); 254 255 - return res.json( 256 - ( 257 - (await db.all<{ 258 - id: number; 259 - user_id: number; 260 - account_id: number; 261 - amount: number; 262 - type: string; 263 - description: string; 264 - date: string; 265 - }>(query, params)) ?? [] 266 - ).map(({ user_id, account_id, ...rest }) => ({ 267 - userId: user_id, 268 - accountId: account_id, 269 - ...rest, 270 - })), 271 - ); 272 - }, 273 - ); 274 275 return app; 276 };
··· 11 JWT_SECRET, 12 TOKEN_EXPIRY_MINUTES, 13 } from "./config"; 14 + import { 15 + Account, 16 + LoginRequest, 17 + TokenPair, 18 + User as UserResponse, 19 + } from "../generated"; 20 import { readFileSync } from "fs"; 21 import { generateRefreshToken, generateToken, verifyToken } from "./auth"; 22 import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema"; 23 import { HttpError } from "express-openapi-validator/dist/framework/types"; 24 + import { AccountService } from "./services/accounts.service"; 25 + import { TransactionService } from "./services/transactions.service"; 26 27 const authenticateToken = async ( 28 req: Request, ··· 55 export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => { 56 const app = express(); 57 58 + const accountService = new AccountService(db); 59 + const transactionService = new TransactionService(db); 60 + 61 app.use(json()); 62 app.use(cors<Request>({ origin: CORS_ORIGIN })); 63 ··· 79 res.send(readFileSync(specPath).toString()); 80 }); 81 82 app.post("/login", async ({ body }: Request<LoginRequest>, res: Response) => { 83 const { username, password } = LoginSchema.parse(body); 84 ··· 86 username, 87 ]); 88 89 + if (!user || !(await compare(password, user.password))) { 90 + return res.status(401).json({ errors: ["Invalid credentials"] }); 91 } 92 93 const now = new Date(); ··· 100 refreshToken: generateRefreshToken(user), 101 }; 102 103 + res.json(response).status(200); 104 }); 105 106 app.post("/refresh-token", async ({ body }: Request, res: Response) => { ··· 146 created: new Date(user.created).toISOString(), 147 }; 148 149 + res?.json(response); 150 }); 151 152 app.get( 153 + "/accounts/:id", 154 authenticateToken, 155 + async ( 156 + { user, params: { id } }: Request<{ id: number }>, 157 + res: Response, 158 + ) => { 159 + const account: Account = await accountService.getAccountById(user, id); 160 161 + if (!account) { 162 + return res 163 + .status(404) 164 + .json({ message: `Account with id ${id} not found` }) 165 + .send(); 166 + } 167 168 + res.json(account); 169 + }, 170 + ); 171 172 + app.get( 173 + "/accounts", 174 + authenticateToken, 175 + async ({ user }: Request, res: Response) => { 176 + res.json(await accountService.getAccountsForUser(user)); 177 }, 178 ); 179 ··· 192 app.get( 193 "/transaction-types", 194 authenticateToken, 195 + async ({ query, user }: Request, res: Response) => { 196 + const { accountId } = TransactionsQuerySchema.parse(query); 197 198 + return res.json( 199 + await transactionService.getTransactionTypes(user.id, accountId), 200 + ); 201 }, 202 ); 203 204 app.get( 205 "/transactions", 206 authenticateToken, 207 + async ({ query, user: { id: userId } }: Request, res: Response) => { 208 + const queryParams = TransactionsQuerySchema.parse(query); 209 210 + const response = await transactionService.paginatedTransactions( 211 + userId, 212 + queryParams, 213 + ); 214 215 + res.json(response); 216 + }, 217 + ); 218 219 + app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 220 + if (err instanceof ZodError) { 221 + res.status(400).json(z.treeifyError(err)); 222 + return; 223 + } 224 225 + if (err instanceof HttpError) { 226 + res.status(err.status).json({ message: err.message }).send(); 227 + return; 228 + } 229 230 + res 231 + .status(500) 232 + .json({ error: `Internal server error: ${err.message}` }) 233 + .send(); 234 + }); 235 236 return app; 237 };
+36
server/src/services/accounts.service.ts
···
··· 1 + import { Database } from "sqlite-async"; 2 + import { Account, User } from "../schema"; 3 + 4 + export class AccountService { 5 + constructor(private db: Database) {} 6 + 7 + getAccountsForUser({ id }: User): Promise<Account[]> { 8 + return this.db.all<Account>(this.getBaseQuery(), [id, id]); 9 + } 10 + 11 + getAccountById({ id }: User, accountId: number): Promise<Account> { 12 + return this.db.get<Account>(this.getBaseQuery() + "AND id = ?", [ 13 + id, 14 + id, 15 + accountId, 16 + ]); 17 + } 18 + 19 + private getBaseQuery(): string { 20 + return ` 21 + SELECT accounts.*, 22 + IFNULL(t.balance, 0) as balance 23 + 24 + FROM accounts 25 + 26 + LEFT JOIN ( 27 + SELECT account_id, SUM(amount) as balance 28 + FROM transactions 29 + WHERE user_id = ? 30 + GROUP BY account_id 31 + ) AS t ON accounts.id = t.account_id 32 + 33 + WHERE accounts.user_id = ? 34 + `; 35 + } 36 + }
+116
server/src/services/transactions.service.ts
···
··· 1 + import { Database } from "sqlite-async"; 2 + import { Transaction, TransactionsQuery, TransactionType } from "../schema"; 3 + import { PaginatedTransactions } from "../../generated"; 4 + 5 + export class TransactionService { 6 + constructor(private db: Database) {} 7 + 8 + getTransactions( 9 + userId: number, 10 + filters: TransactionsQuery, 11 + ): Promise<Transaction[]> { 12 + const [query, params] = this.buildTransactionsQuery(userId, filters); 13 + 14 + const { page, order, limit, sort } = filters; 15 + const offset = (page - 1) * limit; 16 + 17 + params.push(limit, offset); 18 + 19 + const finalQuery = query.concat( 20 + ` ORDER BY ${sort} ${order} LIMIT ? OFFSET ?`, 21 + ); 22 + 23 + return this.db.all<Transaction>(finalQuery, params); 24 + } 25 + 26 + async countTransactions( 27 + userId: number, 28 + filters: TransactionsQuery, 29 + ): Promise<number> { 30 + const [query, params] = this.buildTransactionsQuery(userId, filters); 31 + 32 + const { count } = await this.db.get<{ count: number }>( 33 + query.replace("SELECT *", "SELECT COUNT(*) as count"), 34 + params, 35 + ); 36 + 37 + return count; 38 + } 39 + 40 + async paginatedTransactions( 41 + userId: number, 42 + filters: TransactionsQuery, 43 + ): Promise<PaginatedTransactions> { 44 + const [transactions, total] = await Promise.all([ 45 + this.getTransactions(userId, filters), 46 + this.countTransactions(userId, filters), 47 + ]); 48 + 49 + const { page, limit } = filters; 50 + 51 + return { 52 + data: transactions.map(({ user_id, account_id, ...rest }) => ({ 53 + userId: user_id, 54 + accountId: account_id, 55 + ...rest, 56 + })), 57 + meta: { 58 + total, 59 + page, 60 + limit, 61 + hasMore: total > (page - 1) * limit + transactions.length, 62 + }, 63 + }; 64 + } 65 + 66 + private buildTransactionsQuery( 67 + userId: number, 68 + filters: TransactionsQuery, 69 + ): [string, Array<string | number>] { 70 + const { search, type, accountId } = filters; 71 + 72 + let query = "SELECT * FROM transactions WHERE user_id = ?"; 73 + 74 + let params: Array<string | number> = [userId]; 75 + 76 + if (search.length) { 77 + query += " AND (description LIKE ? OR type LIKE ?)"; 78 + params.push(`%${search}%`, `%${search}%`); 79 + } 80 + 81 + if (accountId) { 82 + query += " AND (account_id = ?)"; 83 + params.push(accountId); 84 + } 85 + 86 + if (type) { 87 + query += " AND (type = ?)"; 88 + params.push(type); 89 + } 90 + 91 + return [query, params]; 92 + } 93 + 94 + getTransactionTypes( 95 + userId: number, 96 + accountId?: number, 97 + ): Promise<TransactionType[]> { 98 + let query = ` 99 + SELECT count(*) as count, 100 + type as name 101 + FROM transactions 102 + WHERE user_id = ? 103 + `; 104 + 105 + let params: Array<string | number> = [userId]; 106 + 107 + if (accountId) { 108 + query += " AND (account_id = ?)"; 109 + params.push(accountId); 110 + } 111 + 112 + query += " GROUP BY type ORDER BY count DESC"; 113 + 114 + return this.db.all<TransactionType>(query, params); 115 + } 116 + }
+23 -6
server/tests/api.spec.ts
··· 3 import { Database } from "sqlite-async"; 4 import { DATABASE_URL } from "../src/config"; 5 import { seed, testUser } from "../src/seeder"; 6 - import { Transaction } from "../generated"; 7 8 type App = ReturnType<typeof build>; 9 let app: App | null = null; ··· 31 }); 32 }); 33 34 describe("GET /transactions", () => { 35 it("it can fetch transactions", async () => { 36 const auth = await request(app).post("/login").send(testUser); ··· 65 .get(`/transactions?accountId=${account.body[0].id}`) 66 .set("Authorization", `Bearer ${auth.body.accessToken}`) 67 .expect(200) 68 - .expect(({ body }) => expect(body).not.toHaveLength(0)) 69 - .expect(({ body }: { body: Transaction[] }) => { 70 - body.forEach((transaction) => { 71 expect(transaction.accountId).toBe(account.body[0].id); 72 }); 73 }); ··· 86 .get(`/transactions?type=${transactionType.body[0].name}`) 87 .set("Authorization", `Bearer ${auth.body.accessToken}`) 88 .expect(200) 89 - .expect(({ body }) => expect(body).not.toHaveLength(0)) 90 .expect(({ body }) => { 91 - body.forEach(({ type }: Transaction) => { 92 expect(type).toBe(transactionType.body[0].name); 93 }); 94 });
··· 3 import { Database } from "sqlite-async"; 4 import { DATABASE_URL } from "../src/config"; 5 import { seed, testUser } from "../src/seeder"; 6 + import { PaginatedTransactions, Transaction } from "../generated"; 7 8 type App = ReturnType<typeof build>; 9 let app: App | null = null; ··· 31 }); 32 }); 33 34 + describe("GET /accounts/:id", () => { 35 + it("it can fetch a single accouns", async () => { 36 + const auth = await request(app).post("/login").send(testUser); 37 + 38 + const accounts = await request(app) 39 + .get("/accounts") 40 + .set("Authorization", `Bearer ${auth.body.accessToken}`) 41 + .expect(({ body }) => expect(body).not.toHaveLength(0)); 42 + 43 + await request(app) 44 + .get(`/accounts/${accounts.body[0].id}`) 45 + .set("Authorization", `Bearer ${auth.body.accessToken}`) 46 + .expect(200) 47 + .expect(({ body }) => expect(body.id).toBe(accounts.body[0].id)); 48 + }); 49 + }); 50 + 51 describe("GET /transactions", () => { 52 it("it can fetch transactions", async () => { 53 const auth = await request(app).post("/login").send(testUser); ··· 82 .get(`/transactions?accountId=${account.body[0].id}`) 83 .set("Authorization", `Bearer ${auth.body.accessToken}`) 84 .expect(200) 85 + .expect(({ body }) => expect(body.data).not.toHaveLength(0)) 86 + .expect(({ body }: { body: PaginatedTransactions }) => { 87 + body.data.forEach((transaction) => { 88 expect(transaction.accountId).toBe(account.body[0].id); 89 }); 90 }); ··· 103 .get(`/transactions?type=${transactionType.body[0].name}`) 104 .set("Authorization", `Bearer ${auth.body.accessToken}`) 105 .expect(200) 106 + .expect(({ body }) => expect(body.data).not.toHaveLength(0)) 107 .expect(({ body }) => { 108 + body.data.forEach(({ type }: Transaction) => { 109 expect(type).toBe(transactionType.body[0].name); 110 }); 111 });