├── .gitignore ├── public ├── logo-32.png ├── logo-64.png ├── logo-128.png ├── logo-256.png ├── logo-512.png ├── images │ ├── home │ │ └── hero.jpg │ └── wallet.svg └── error.svg ├── flows ├── User Payments Flow.jpg ├── Dashout - Order Flow.jpg └── Billing Flow.md ├── API ├── ServerSideAPIs │ ├── transactions.ts │ ├── merchants.ts │ └── orders.ts ├── transactions.ts ├── repayments.ts ├── auth.ts └── merchants.ts ├── .env.example ├── types ├── merchant.ts ├── storeState.ts ├── order.ts ├── transaction.ts └── user.ts ├── next-env.d.ts ├── helpers ├── razorpay.ts ├── verifyIDToken.ts ├── copyText.ts ├── toasts.ts ├── setupProtectedRoute.ts └── request.ts ├── firebase ├── firestore.ts ├── index.ts ├── admin.ts ├── authentication.ts └── firestore-security-rules ├── hooks ├── useUser.ts └── useFirestore.ts ├── components ├── Layout │ ├── ContentWrapper.tsx │ ├── Error.tsx │ ├── NoneFound.tsx │ ├── AppContentContainer.tsx │ ├── FullPageLoader.tsx │ ├── index.tsx │ ├── GlobalStyles.tsx │ └── Header.tsx ├── Modal.tsx ├── Authentication │ ├── LoginModal.tsx │ └── index.tsx ├── FormControl.tsx ├── Button.tsx ├── Merchants │ ├── MerchantCard.tsx │ └── MerchantCreatorModal.tsx └── TransactionTile.tsx ├── store ├── useStore.ts └── index.ts ├── README.md ├── tsconfig.json ├── package.json └── pages ├── api ├── getOrderDetails.ts ├── createOrder.ts ├── declineOrder.ts ├── confirmOrder.ts ├── createBillSettlementTransaction.ts └── settleUserBill.ts ├── profile └── [userId].tsx ├── _app.tsx ├── index.tsx ├── repayment └── [orderId].tsx ├── user ├── merchants.tsx └── tab.tsx └── pay └── [orderId].tsx /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .next 4 | keys/* -------------------------------------------------------------------------------- /public/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-32.png -------------------------------------------------------------------------------- /public/logo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-64.png -------------------------------------------------------------------------------- /public/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-128.png -------------------------------------------------------------------------------- /public/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-256.png -------------------------------------------------------------------------------- /public/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/logo-512.png -------------------------------------------------------------------------------- /flows/User Payments Flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/flows/User Payments Flow.jpg -------------------------------------------------------------------------------- /public/images/home/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/public/images/home/hero.jpg -------------------------------------------------------------------------------- /flows/Dashout - Order Flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deve-sh/Dashout/HEAD/flows/Dashout - Order Flow.jpg -------------------------------------------------------------------------------- /API/ServerSideAPIs/transactions.ts: -------------------------------------------------------------------------------- 1 | import admin from "../../firebase/admin"; 2 | 3 | // Create and manipulate user transactions here. 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FIREBASE_SERVICE_ACCOUNT= # Service Account JSON 2 | NEXT_PUBLIC_FIREBASE_CONFIG= # Firebase Config JSON 3 | NEXT_PUBLIC_RAZORPAY_KEY_ID= 4 | RAZORPAY_KEY_SECRET= -------------------------------------------------------------------------------- /types/merchant.ts: -------------------------------------------------------------------------------- 1 | export default interface Merchant { 2 | merchantName: string; 3 | photoURL: string; 4 | merchantEmail: string; 5 | webhookURL: string; 6 | successRedirect: string; 7 | errorRedirect: string; 8 | } 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /helpers/razorpay.ts: -------------------------------------------------------------------------------- 1 | import razorpaySDK from "razorpay"; 2 | 3 | const razorpay = new razorpaySDK({ 4 | key_id: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, 5 | key_secret: process.env.RAZORPAY_KEY_SECRET, 6 | }); 7 | 8 | export default razorpay; 9 | -------------------------------------------------------------------------------- /firebase/firestore.ts: -------------------------------------------------------------------------------- 1 | import firebase from "./index"; 2 | import mainFirebase from "firebase/app"; 3 | import "firebase/firestore"; 4 | 5 | const db = firebase.firestore(); 6 | 7 | export default db; 8 | export const firestore = mainFirebase.firestore; 9 | -------------------------------------------------------------------------------- /hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import useStore from "../store/useStore"; 2 | import User from "../types/user"; 3 | 4 | const useUser = () => { 5 | const user: User = useStore((state) => state.user); 6 | return user; 7 | }; 8 | 9 | export default useUser; 10 | -------------------------------------------------------------------------------- /types/storeState.ts: -------------------------------------------------------------------------------- 1 | import User from "./user"; 2 | 3 | export default interface State { 4 | user: null | undefined | User; 5 | setUser: (user: any) => any; 6 | isDarkModeActive: boolean; 7 | toggleDarkMode: () => any; 8 | isLoading: boolean; 9 | loaderType: string; 10 | } 11 | -------------------------------------------------------------------------------- /flows/Billing Flow.md: -------------------------------------------------------------------------------- 1 | ## User Billing Flow 2 | 3 | We'll be using a monthly billing cycle for the user. 4 | 5 | - No matter the user's sign up date, the bill comes due on the last day of the month. 6 | - Any funds not paid for by the user will be added/rolled over to the next month's bill. 7 | -------------------------------------------------------------------------------- /types/order.ts: -------------------------------------------------------------------------------- 1 | export interface OrderItem { 2 | name: string; 3 | desc?: string; 4 | externalId: string; 5 | pricePerUnit: number; 6 | quantity: number; 7 | currency?: string; 8 | } 9 | 10 | export interface OrderDetails extends OrderItem { 11 | merchant: string; 12 | status: string; 13 | } 14 | -------------------------------------------------------------------------------- /components/Layout/ContentWrapper.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Container } from "@chakra-ui/react"; 3 | 4 | const ContentWrapper = styled(Container)` 5 | max-width: 1100px; 6 | margin: 0 auto; 7 | padding: var(--standard-spacing); 8 | `; 9 | 10 | export default ContentWrapper; 11 | -------------------------------------------------------------------------------- /types/transaction.ts: -------------------------------------------------------------------------------- 1 | import type { OrderDetails } from "./order"; 2 | 3 | export default interface Transaction { 4 | id?: string; 5 | order: string; 6 | orderDetails: OrderDetails; 7 | merchant: string; 8 | amount: string | number; 9 | user: string; 10 | createdAt: any; 11 | updatedAt: any; 12 | } 13 | -------------------------------------------------------------------------------- /helpers/verifyIDToken.ts: -------------------------------------------------------------------------------- 1 | import admin from "../firebase/admin"; 2 | 3 | const verifyIDToken = async (token: string) => { 4 | try { 5 | const decodedToken = await admin.auth().verifyIdToken(token); 6 | return decodedToken; 7 | } catch (err) { 8 | return null; 9 | } 10 | }; 11 | 12 | export default verifyIDToken; 13 | -------------------------------------------------------------------------------- /helpers/copyText.ts: -------------------------------------------------------------------------------- 1 | const copyText = (text: string) => { 2 | let tempInput = document.createElement("input"); 3 | tempInput.value = text; 4 | document.body.appendChild(tempInput); 5 | tempInput.select(); 6 | document.execCommand("copy"); 7 | document.body.removeChild(tempInput); 8 | }; 9 | 10 | export default copyText; 11 | -------------------------------------------------------------------------------- /store/useStore.ts: -------------------------------------------------------------------------------- 1 | /** Hook to use zustand store in reactive React Components. **/ 2 | 3 | import create from "zustand"; 4 | import store from "./index"; 5 | 6 | import State from "../types/storeState"; 7 | 8 | const useStore = create(store); 9 | 10 | export default useStore as (selector: (state: State) => any) => any; 11 | -------------------------------------------------------------------------------- /firebase/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | The main firebase configuration file. 3 | */ 4 | 5 | import firebase from "firebase/app"; 6 | 7 | const config = JSON.parse(process.env.NEXT_PUBLIC_FIREBASE_CONFIG); 8 | 9 | const firebasePrimaryApp = !firebase.apps.length 10 | ? firebase.initializeApp(config) 11 | : firebase.apps[0]; 12 | 13 | export default firebasePrimaryApp; 14 | -------------------------------------------------------------------------------- /firebase/admin.ts: -------------------------------------------------------------------------------- 1 | import admin from "firebase-admin"; 2 | 3 | const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT); 4 | 5 | serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, "\n"); 6 | 7 | if (!admin.apps.length) { 8 | admin.initializeApp({ 9 | credential: admin.credential.cert(serviceAccount), 10 | }); 11 | } 12 | 13 | export default admin; 14 | -------------------------------------------------------------------------------- /types/user.ts: -------------------------------------------------------------------------------- 1 | interface User { 2 | uid: string; 3 | email: string; 4 | displayName: string; 5 | providerInfo?: any; 6 | emailVerified?: boolean; 7 | phoneNumber?: string; 8 | photoURL?: string; 9 | disabled?: boolean; 10 | nTransactions?: number; 11 | totalTransactionAmount?: number; 12 | totalAmountRepaid?: number; 13 | createdAt?: string; 14 | lastSignIn?: string; 15 | canCreateMerchants?: boolean; 16 | } 17 | 18 | export default User; 19 | -------------------------------------------------------------------------------- /components/Layout/Error.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Image, Text } from "@chakra-ui/react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const ErrorImage = styled(Image)` 5 | max-width: 45vw; 6 | `; 7 | 8 | export default function Error({ errorMessage }) { 9 | return ( 10 | 11 | 16 |
17 | {errorMessage} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dashout 2 | 3 |

4 | 5 |

6 | 7 | A simple project that acts as a template for a buy now pay later service. 8 | 9 | [Read Related Blog post Here](https://blog.devesh.tech/post/lets-build-a-buy-now-pay-later-service). 10 | 11 | **Disclaimer**: Obviously this is a sample app and by no means an attempt to start a business that would require a lot of regulatory checks, this entire project is sandboxed and is just a template, no real money will be involved in this project (Obviously). 12 | -------------------------------------------------------------------------------- /components/Layout/NoneFound.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Text, Icon } from "@chakra-ui/react"; 2 | import { MdFilterList } from "react-icons/md"; 3 | 4 | const NoneFound = ({ label = "", icon = undefined }) => ( 5 | 15 | )} /> 16 | 17 | {label} 18 | 19 | 20 | ); 21 | 22 | export default NoneFound; 23 | -------------------------------------------------------------------------------- /API/transactions.ts: -------------------------------------------------------------------------------- 1 | import db from "../firebase/firestore"; 2 | 3 | export const getUserTransactions = async ( 4 | userId: string, 5 | startAfter: any, 6 | callback: (errorMessage: string, transactionList: any[]) => any 7 | ) => { 8 | try { 9 | let transactionsRef = db 10 | .collection("transactions") 11 | .where("user", "==", userId); 12 | if (startAfter) transactionsRef = transactionsRef.startAfter(startAfter); 13 | transactionsRef = transactionsRef.orderBy("createdAt", "desc").limit(10); 14 | return callback(null, (await transactionsRef.get()).docs); 15 | } catch (err) { 16 | console.log(err); 17 | return callback(err.message, []); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /components/Layout/AppContentContainer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const AppContent = styled.div` 5 | padding-top: 15rem; 6 | `; 7 | 8 | const AppContentContainer = ({ children }) => { 9 | const contentContainerRef = useRef(null); 10 | 11 | useEffect(() => { 12 | const headerElement = document.getElementById("app-header"); 13 | if (contentContainerRef.current && headerElement) 14 | contentContainerRef.current.style.paddingTop = 15 | headerElement.offsetHeight + "px"; 16 | }, []); 17 | 18 | return {children}; 19 | }; 20 | 21 | export default AppContentContainer; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "incremental": true, 15 | "esModuleInterop": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "preserve" 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /firebase/authentication.ts: -------------------------------------------------------------------------------- 1 | import firebase from "./index"; 2 | import mainFirebase from "firebase/app"; 3 | import "firebase/auth"; 4 | import Cookie from "js-cookie"; 5 | 6 | const auth = firebase.auth(); 7 | 8 | // Providers 9 | const googleProvider = new mainFirebase.auth.GoogleAuthProvider(); 10 | const githubProvider = new mainFirebase.auth.GithubAuthProvider(); 11 | 12 | export default auth; 13 | export const providers = { 14 | googleProvider, 15 | githubProvider, 16 | }; 17 | 18 | export const getToken = async (refresh = false) => { 19 | let cookie = Cookie.get("accessToken") || null; 20 | if (!cookie) cookie = (await auth.currentUser?.getIdToken?.(refresh)) || null; 21 | return cookie; 22 | }; 23 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalFooter, 8 | ModalBody, 9 | ModalCloseButton, 10 | } from "@chakra-ui/react"; 11 | 12 | const ReusableModal = ({ 13 | children, 14 | title = "", 15 | isOpen = false, 16 | onClose = () => null, 17 | actionButton = undefined, 18 | }) => ( 19 | 20 | 21 | 22 | {title} 23 | 24 | {children} 25 | 26 | 27 | 30 | {actionButton || ""} 31 | 32 | 33 | 34 | ); 35 | 36 | export default ReusableModal; 37 | -------------------------------------------------------------------------------- /store/index.ts: -------------------------------------------------------------------------------- 1 | import create from "zustand/vanilla"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | import State from "../types/storeState"; 5 | 6 | const store = create( 7 | persist( 8 | (set) => ({ 9 | // User Auth 10 | user: null, 11 | setUser: (user = null) => set((state) => ({ ...state, user })), 12 | // Loader 13 | isLoading: false, 14 | loaderType: "loader", 15 | setLoading: (isLoading = false, loaderType = "loader") => 16 | set((state: State) => ({ ...state, isLoading, loaderType })), 17 | // Dark Mode 18 | isDarkModeActive: false, 19 | toggleDarkMode: () => 20 | set((state: State) => ({ 21 | ...state, 22 | isDarkModeActive: !state.isDarkModeActive, 23 | })), 24 | }), 25 | { 26 | name: "dashout-storage", 27 | } 28 | ) 29 | ); 30 | 31 | export default store; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashout", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev -p 3004", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@chakra-ui/react": "^1.7.3", 12 | "@emotion/react": "^11.7.1", 13 | "@emotion/styled": "^11.6.0", 14 | "axios": "^0.24.0", 15 | "firebase": "^8.10.0", 16 | "firebase-admin": "^10.0.2", 17 | "framer-motion": "^5.5.5", 18 | "js-cookie": "^3.0.1", 19 | "next": "^12.0.10", 20 | "postcss": "^8.4.6", 21 | "razorpay": "^2.8.0", 22 | "react": "17.0.2", 23 | "react-dom": "17.0.2", 24 | "react-icons": "^4.3.1", 25 | "uuid": "^8.3.2", 26 | "zustand": "^3.6.8" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^17.0.4", 30 | "@types/react": "^17.0.38", 31 | "typescript": "^4.5.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /components/Layout/FullPageLoader.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Spinner, Skeleton } from "@chakra-ui/react"; 3 | 4 | const FullPageLoaderContainer = styled.div` 5 | position: fixed; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | text-align: center; 10 | z-index: 1000; 11 | background: var(--white); 12 | bottom: 0; 13 | left: 0; 14 | top: 0; 15 | right: 0; 16 | overflow: hidden; 17 | `; 18 | 19 | const StyledLoaderSkeleton = styled(Skeleton)` 20 | min-height: 100vh; 21 | width: 100vw; 22 | `; 23 | 24 | const FullPageLoader = ({ type = "loader" }) => ( 25 | 26 | {type === "loader" ? ( 27 | 28 | ) : ( 29 | 30 | )} 31 | 32 | ); 33 | 34 | export default FullPageLoader; 35 | -------------------------------------------------------------------------------- /API/ServerSideAPIs/merchants.ts: -------------------------------------------------------------------------------- 1 | import admin from "../../firebase/admin"; 2 | 3 | // Create, fetch and manipulate merchants here. 4 | export const getMerchant = async (clientId: string, clientSecret: string) => { 5 | try { 6 | const merchant = ( 7 | await admin 8 | .firestore() 9 | .collection("merchants") 10 | .where("clientId", "==", clientId) 11 | .where("clientSecret", "==", clientSecret) 12 | .limit(1) 13 | .get() 14 | ).docs[0]; 15 | return merchant || null; 16 | } catch (err) { 17 | console.log(err); 18 | return null; 19 | } 20 | }; 21 | 22 | export const getMerchantById = async (merchantId: string) => { 23 | try { 24 | const merchant = ( 25 | await admin.firestore().collection("merchants").doc(merchantId).get() 26 | ).data(); 27 | return merchant || null; 28 | } catch (err) { 29 | console.log(err); 30 | return null; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /hooks/useFirestore.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import db from "../firebase/firestore"; 3 | 4 | const useFirestore = (docPath) => { 5 | let [docData, setDocData] = useState({ 6 | fetching: true, 7 | error: null, 8 | data: null, 9 | }); 10 | let subscriptionRef = useRef(null); 11 | 12 | useEffect(() => { 13 | if (!docPath) return; 14 | 15 | let ref = db.doc(docPath); 16 | subscriptionRef.current = ref.onSnapshot( 17 | (doc) => { 18 | const newDocData = { fetching: false, error: null, data: doc.data() }; 19 | setDocData(newDocData); 20 | }, 21 | (error) => setDocData({ fetching: false, error, data: null }) 22 | ); 23 | 24 | return () => { 25 | if (subscriptionRef.current instanceof Function) 26 | subscriptionRef.current(); // Unsubscribe from document's real-time updates. 27 | }; 28 | }, [docPath]); 29 | 30 | return docData; 31 | }; 32 | 33 | export default useFirestore; 34 | -------------------------------------------------------------------------------- /components/Authentication/LoginModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalOverlay, 4 | ModalContent, 5 | ModalHeader, 6 | ModalFooter, 7 | ModalBody, 8 | Button, 9 | } from "@chakra-ui/react"; 10 | import styled from "@emotion/styled"; 11 | 12 | import Authentication from "./index"; 13 | 14 | const LoginModalBody = styled(ModalBody)` 15 | text-align: center; 16 | padding: calc(2 * var(--standard-spacing)); 17 | padding-bottom: var(--standard-spacing); 18 | `; 19 | 20 | const LoginModal = ({ isOpen, closeModal }) => { 21 | return ( 22 | 23 | 24 | 25 | 26 | Login 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default LoginModal; 42 | -------------------------------------------------------------------------------- /helpers/toasts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStandaloneToast, 3 | ToastPositionWithLogical, 4 | } from "@chakra-ui/react"; 5 | 6 | const toastOps = ( 7 | title: string, 8 | desc?: string, 9 | status: "info" | "warning" | "success" | "error" = "info" 10 | ) => ({ 11 | title, 12 | description: desc || "", 13 | status: status || "info", 14 | duration: 4500, 15 | isClosable: true, 16 | position: "bottom-left" as ToastPositionWithLogical, 17 | }); 18 | 19 | const toasts = { 20 | info: (message: string, desc?: string): any => { 21 | const toast = createStandaloneToast(); 22 | return toast(toastOps(message, desc)); 23 | }, 24 | success: (successMessage: string = "", desc?: string): any => { 25 | const toast = createStandaloneToast(); 26 | return toast(toastOps(successMessage, desc, "success")); 27 | }, 28 | error: (errorMessage: string = "", desc?: string): any => { 29 | const toast = createStandaloneToast(); 30 | return toast(toastOps(errorMessage, desc, "error")); 31 | }, 32 | warn: (warningMessage: string = "", desc?: string): any => { 33 | const toast = createStandaloneToast(); 34 | return toast(toastOps(warningMessage, desc, "warning")); 35 | }, 36 | }; 37 | 38 | export default toasts; 39 | -------------------------------------------------------------------------------- /components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Common App Layout 3 | */ 4 | 5 | import dynamic from "next/dynamic"; 6 | import { ChakraProvider } from "@chakra-ui/react"; 7 | 8 | import GlobalStyles from "./GlobalStyles"; 9 | import Header from "./Header"; 10 | import AppContentContainer from "./AppContentContainer"; 11 | 12 | import useStore from "../../store/useStore"; 13 | 14 | const LoginModal = dynamic(() => import("../Authentication/LoginModal")); 15 | 16 | const AppLayout = ({ 17 | children, 18 | logoutUser = () => null, 19 | showLoginModal = false, 20 | openLoginModal = () => null, 21 | closeLoginModal = () => null, 22 | }) => { 23 | const isDarkModeActive = useStore((store) => store.isDarkModeActive); 24 | 25 | // User Authentication 26 | const stateUser = useStore((store) => store.user); 27 | 28 | return ( 29 | 30 | 31 |
32 | {!stateUser && ( 33 | 34 | )} 35 | {children} 36 | 37 | ); 38 | }; 39 | 40 | export default AppLayout; 41 | -------------------------------------------------------------------------------- /components/FormControl.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FormControl as ChakraFormControl, 3 | FormLabel, 4 | FormErrorMessage, 5 | FormHelperText, 6 | Input, 7 | } from "@chakra-ui/react"; 8 | import { FormEvent } from "react"; 9 | 10 | interface FormControlProps { 11 | id: string; 12 | type?: string; 13 | helperText?: string; 14 | label: string; 15 | isRequired?: boolean; 16 | isInvalid?: boolean; 17 | isLoading?: boolean; 18 | name: string; 19 | placeholder?: string; 20 | // For controlled form-control inputs 21 | onChange?: (e: FormEvent) => any; 22 | value?: string; 23 | } 24 | 25 | const FormControl = ({ 26 | id, 27 | type = "text", 28 | label, 29 | placeholder = "", 30 | helperText, 31 | isRequired = false, 32 | isInvalid = false, 33 | isLoading = false, 34 | onChange = undefined, 35 | value = undefined, 36 | name, 37 | }: FormControlProps) => ( 38 | 39 | {label} 40 | 49 | {helperText && {helperText}} 50 | 51 | ); 52 | 53 | export default FormControl; 54 | -------------------------------------------------------------------------------- /helpers/setupProtectedRoute.ts: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import Cookie from "js-cookie"; 3 | 4 | export default function setupProtectedRoute(callback?: (ctx: any) => any) { 5 | return async function getInitialProps(ctx) { 6 | const { req, res, asPath } = ctx; 7 | const isServer = typeof window === "undefined"; 8 | 9 | const redirectTo = `/`; // Use this to log user in later using a login page redirect. 10 | 11 | if (isServer) { 12 | if (req?.cookies?.accessToken) { 13 | // Allowed 14 | if (callback) { 15 | const valuesToReturnAsInitialProps = await callback(ctx); 16 | return valuesToReturnAsInitialProps; 17 | } 18 | return { protected: true }; 19 | } 20 | // Redirect to home page. 21 | res?.writeHead?.(302, { 22 | Location: redirectTo, 23 | }); 24 | res?.end?.(); 25 | } else { 26 | if (Cookie.get("accessToken")) { 27 | // Allowed 28 | if (callback) { 29 | const valuesToReturnAsInitialProps = await callback(ctx); 30 | return valuesToReturnAsInitialProps; 31 | } 32 | return { protected: true }; 33 | } 34 | Router.push(redirectTo); 35 | return { protected: true }; 36 | } 37 | return { protected: true }; 38 | }; 39 | } 40 | 41 | /** 42 | * Usage: 43 | * 44 | * PageComponent.getInitialProps = setupProtectedRoute(); 45 | */ 46 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Button as ChakraButton } from "@chakra-ui/react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const Button = styled(ChakraButton)` 5 | padding: calc( 6 | ${({ $paddingMultiplier }) => $paddingMultiplier || "1.25"} * 7 | var(--standard-spacing) 8 | ); 9 | font-size: var(--standard-spacing); 10 | color: var(--white); 11 | width: fit-content; 12 | min-width: ${({ $noMinWidth }) => 13 | !$noMinWidth ? "calc(12.5 * var(--standard-spacing))" : ""}; 14 | background: var(--primary); 15 | text-transform: capitalize; 16 | font-weight: 500; 17 | letter-spacing: calc(0.075 * var(--standard-spacing)); 18 | 19 | ${({ $variant }) => 20 | $variant === "hollow" && 21 | "border: calc(0.075 * var(--standard-spacing)) solid var(--primary);"} 22 | ${({ $variant }) => $variant === "hollow" && "background: var(--white);"} 23 | ${({ $variant }) => $variant === "hollow" && "color: var(--primary);"} 24 | ${({ $variant }) => 25 | $variant === "hollow" 26 | ? ` 27 | &:hover, 28 | &:focus, 29 | &:active { 30 | background: var(--white); 31 | color: var(--primary); 32 | border: calc(0.075 * var(--standard-spacing)) solid var(--primary); 33 | }` 34 | : ` 35 | &:hover, 36 | &:focus, 37 | &:active { 38 | background: var(--primary); 39 | }`} 40 | `; 41 | 42 | export default Button; 43 | -------------------------------------------------------------------------------- /API/repayments.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "../firebase/authentication"; 2 | import request from "../helpers/request"; 3 | 4 | export const createRepaymentTransaction = async ( 5 | callback: (errorMessage: string | null, orderInfo?: any) => any 6 | ) => { 7 | try { 8 | request({ 9 | endpoint: "/api/createBillSettlementTransaction", 10 | options: { headers: { authorization: await getToken() } }, 11 | requestType: "post", 12 | callback: (error, response) => { 13 | if (error) return callback(error); 14 | return callback(null, response); 15 | }, 16 | }); 17 | } catch (err) { 18 | console.log(err); 19 | return callback(err.message); 20 | } 21 | }; 22 | 23 | interface RazorpayPaymentResponse { 24 | razorpay_payment_id: string; 25 | razorpay_order_id: string; 26 | razorpay_signature: string; 27 | } 28 | 29 | export const verifyRepayment = async ( 30 | razorpayResponse: RazorpayPaymentResponse, 31 | callback: (error?: string) => any 32 | ) => { 33 | request({ 34 | endpoint: "/api/settleUserBill", 35 | data: { 36 | razorpay_payment_id: razorpayResponse.razorpay_payment_id, 37 | razorpay_order_id: razorpayResponse.razorpay_order_id, 38 | razorpay_signature: razorpayResponse.razorpay_signature, 39 | status: "successful", 40 | }, 41 | options: { headers: { authorization: await getToken() } }, 42 | requestType: "post", 43 | callback, 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /API/auth.ts: -------------------------------------------------------------------------------- 1 | import auth, { providers } from "../firebase/authentication"; 2 | import db, { firestore } from "../firebase/firestore"; 3 | import User from "../types/user"; 4 | 5 | export const loginWithGoogle = async ( 6 | callback: (errorMessage: string | null) => any 7 | ) => { 8 | try { 9 | if (!auth.currentUser) { 10 | await auth.signInWithPopup(providers.googleProvider); 11 | return callback(null); 12 | } else return callback("User already signed in."); 13 | } catch (err) { 14 | console.log(err); 15 | return callback(err.message); 16 | } 17 | }; 18 | 19 | export const loginWithGithub = async ( 20 | callback: (errorMessage: string | null) => any 21 | ) => { 22 | try { 23 | if (!auth.currentUser) { 24 | await auth.signInWithPopup(providers.githubProvider); 25 | return callback(null); 26 | } else return callback("User already signed in."); 27 | } catch (err) { 28 | console.log(err); 29 | return callback(err.message); 30 | } 31 | }; 32 | 33 | export const saveUserProfileToFirestore = async ( 34 | userId: string, 35 | userInfo: User, 36 | callback: (errorMessage: string | null, userDataFromFirestore?: any) => any 37 | ) => { 38 | try { 39 | const userRef = db.collection("users").doc(userId); 40 | await userRef.set( 41 | { ...userInfo, updatedAt: firestore.FieldValue.serverTimestamp() }, 42 | { merge: true } 43 | ); 44 | return callback(null, (await userRef.get()).data()); 45 | } catch (err) { 46 | console.log(err); 47 | return callback(err.message); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /components/Merchants/MerchantCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, HStack, IconButton, Text } from "@chakra-ui/react"; 2 | import { MdOutlineContentCopy } from "react-icons/md"; 3 | 4 | import copyText from "../../helpers/copyText"; 5 | import toasts from "../../helpers/toasts"; 6 | 7 | const MerchantCard = ({ merchant }) => { 8 | return ( 9 | 16 | 17 | 18 | {merchant.merchantName} 19 | 20 | ₹{Number((merchant?.amountProcessed || 0) / 100).toFixed(2) || 0} 21 | 22 | 23 | { 29 | copyText( 30 | JSON.stringify( 31 | { 32 | clientId: merchant.clientId, 33 | clientSecret: merchant.clientSecret, 34 | }, 35 | null, 36 | 4 37 | ) 38 | ); 39 | toasts.info("Copied Client Id and Secret to Clipboard"); 40 | }} 41 | > 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | export default MerchantCard; 50 | -------------------------------------------------------------------------------- /components/Authentication/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button } from "@chakra-ui/react"; 3 | 4 | import { FcGoogle } from "react-icons/fc"; 5 | import { FaGithub } from "react-icons/fa"; 6 | 7 | import { loginWithGoogle, loginWithGithub } from "../../API/auth"; 8 | import toasts from "../../helpers/toasts"; 9 | 10 | const Authentication = () => { 11 | const [isLoggingIn, setIsLoggingIn] = useState(false); 12 | 13 | const signInUser = (mode = "google") => { 14 | // Use 'mode' in the future to distinguish between multiple OAuth Based login modes. 15 | setIsLoggingIn(true); 16 | const callback = (err) => { 17 | setIsLoggingIn(false); 18 | if (err) return toasts.error(err); 19 | }; 20 | if (mode === "google") loginWithGoogle(callback); 21 | else if (mode === "google") loginWithGithub(callback); 22 | }; 23 | 24 | return ( 25 | <> 26 | 37 | 49 | 50 | ); 51 | }; 52 | 53 | export default Authentication; 54 | -------------------------------------------------------------------------------- /pages/api/getOrderDetails.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants"; 4 | import { getOrder } from "../../API/ServerSideAPIs/orders"; 5 | 6 | import verifyIDToken from "../../helpers/verifyIDToken"; 7 | 8 | export default async function getOrderAndMerchant( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const error = (status: number, message: string) => 13 | res.status(status).json({ 14 | error: message, 15 | message, 16 | }); 17 | 18 | try { 19 | const { authorization } = req.headers; 20 | const { orderId } = req.body as { orderId: string }; 21 | 22 | if (!orderId) return error(403, "Invalid credentials."); 23 | 24 | const decodedToken = await verifyIDToken(authorization); 25 | if (!decodedToken || !decodedToken.uid) 26 | return error(403, "Invalid credentials"); 27 | 28 | const order = await getOrder(orderId); 29 | if (!order || !order.merchant) return error(404, "Order Not Found"); 30 | 31 | const merchant = await getMerchantById(order.merchant); 32 | if (!merchant) return error(404, "Merchant Not Found"); 33 | 34 | return res.status(200).json({ 35 | message: "Fetched Order details successfully", 36 | order, 37 | merchant: { 38 | merchantName: merchant.merchantName, 39 | photoURL: merchant.photoURL, 40 | errorRedirect: merchant.errorRedirect, 41 | successRedirect: merchant.successRedirect, 42 | }, 43 | }); 44 | } catch (err) { 45 | return error(500, err.message); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pages/api/createOrder.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import type { OrderItem } from "../../types/order"; 3 | 4 | import { getMerchant } from "../../API/ServerSideAPIs/merchants"; 5 | import { createNewOrder } from "../../API/ServerSideAPIs/orders"; 6 | 7 | export default async function createOrder( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | const error = (status: number, message: string) => 12 | res.status(status).json({ 13 | error: message, 14 | message, 15 | }); 16 | 17 | try { 18 | const { authorization } = req.headers; 19 | const { item } = req.body as { item: OrderItem }; 20 | 21 | const clientId = authorization.split(":")[0]; 22 | const clientSecret = authorization.split(":")[1]; 23 | 24 | if (!clientId || !clientSecret) return error(403, "Invalid credentials."); 25 | if (!item || !item.name || !item.quantity || !item.pricePerUnit) 26 | return error(400, "Incomplete Information"); 27 | 28 | const merchant = await getMerchant(clientId, clientSecret); 29 | if (!merchant) return error(403, "Invalid credentials"); 30 | 31 | await createNewOrder(merchant.id, item, (err, createdOrder) => { 32 | if (err) return error(500, "Failed to create order."); 33 | else { 34 | createdOrder.createdAt = createdOrder.createdAt.toDate(); 35 | createdOrder.updatedAt = createdOrder.updatedAt.toDate(); 36 | return res.status(201).json({ 37 | message: "Created order successfully.", 38 | order: createdOrder, 39 | }); 40 | } 41 | }); 42 | } catch (err) { 43 | return error(500, err.message); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/Layout/GlobalStyles.tsx: -------------------------------------------------------------------------------- 1 | import { Global, css } from "@emotion/react"; 2 | 3 | const GlobalStyles = ({ darkMode = false }) => ( 4 | 79 | ); 80 | 81 | export default GlobalStyles; 82 | -------------------------------------------------------------------------------- /components/TransactionTile.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Box, Stat, StatNumber, Text, VStack, Icon } from "@chakra-ui/react"; 3 | import type Transaction from "../types/transaction"; 4 | 5 | import { FaClock } from "react-icons/fa"; 6 | 7 | interface TransactionTileProps { 8 | transaction: Transaction; 9 | } 10 | 11 | const TransactionTile = ({ transaction }: TransactionTileProps) => { 12 | return transaction ? ( 13 | 59 | ) : ( 60 | <> 61 | ); 62 | }; 63 | 64 | export default TransactionTile; 65 | -------------------------------------------------------------------------------- /helpers/request.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | interface RequestParams { 4 | endpoint: string; 5 | data?: any; 6 | options?: any; 7 | requestType?: "get" | "post" | "patch" | "delete" | "put"; 8 | callback?: (error?: any, response?: any) => any; 9 | tries?: number; 10 | prevError?: any; 11 | } 12 | 13 | const request = async ({ 14 | endpoint, 15 | data, 16 | options, 17 | requestType, 18 | callback, 19 | // For retry scenarios 20 | tries = 3, 21 | prevError = null, 22 | }: RequestParams) => { 23 | try { 24 | if (!tries) throw new Error(prevError); 25 | const response = await axios[requestType](endpoint, data, options); 26 | if (callback && typeof callback === "function") 27 | return callback(null, response.data); 28 | else return response.data; 29 | } catch (err) { 30 | console.log(err); 31 | 32 | // Request failed, check if retry has been enabled and if tries are left. 33 | const triesLeft = tries - 1; 34 | if ( 35 | ((!err?.request && !err?.response) || // Couldn't make a request in the first place. 36 | (err?.request && !err?.response) || // Made request. Didn't get any response, i.e: something like CORS 37 | (err?.response?.status && Number(err?.response?.status) >= 500)) && // All other errors mean the user is doing something wrong, hence no retries 38 | Number(triesLeft) > 0 39 | ) { 40 | console.log(`Retrying ${endpoint}, tries left: `, triesLeft); 41 | return request({ 42 | endpoint, 43 | data, 44 | options, 45 | requestType, 46 | callback, 47 | tries: triesLeft, 48 | prevError: err, 49 | }); 50 | } else { 51 | if (callback && typeof callback === "function") { 52 | return callback( 53 | err?.response?.data?.error || 54 | err?.message || 55 | "Something went wrong. Please try again later.", 56 | null 57 | ); 58 | } else return null; 59 | } 60 | } 61 | }; 62 | 63 | export default request; 64 | -------------------------------------------------------------------------------- /pages/api/declineOrder.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants"; 5 | import { declineOrderForUser, getOrder } from "../../API/ServerSideAPIs/orders"; 6 | 7 | import verifyIDToken from "../../helpers/verifyIDToken"; 8 | 9 | export default async function declineOrder( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | const error = (status: number, message: string) => 14 | res.status(status).json({ 15 | error: message, 16 | message, 17 | }); 18 | 19 | try { 20 | const { authorization } = req.headers; 21 | const { orderId } = req.body as { orderId: string }; 22 | 23 | if (!orderId) return error(403, "Invalid credentials."); 24 | 25 | const decodedToken = await verifyIDToken(authorization); 26 | if (!decodedToken || !decodedToken.uid) 27 | return error(403, "Invalid credentials"); 28 | 29 | const order = await getOrder(orderId); 30 | if (!order || !order.merchant) return error(404, "Order Not Found"); 31 | 32 | const merchant = await getMerchantById(order.merchant); 33 | if (!merchant) return error(404, "Merchant Not Found"); 34 | 35 | if (order.status === "fulfilled") 36 | return error(400, "Order already fulfilled"); 37 | if (order.status === "declined") 38 | return error(400, "Order already declined"); 39 | 40 | await declineOrderForUser(orderId, decodedToken.uid, (err) => { 41 | if (err) return error(500, "Order could not be declined"); 42 | if (merchant.webhookURL) 43 | axios.post(merchant.webhookURL, { 44 | orderId, 45 | status: "declined", 46 | type: "order_declined", 47 | }); 48 | return res.status(200).json({ 49 | message: "Declined Order Successfully", 50 | redirectTo: merchant.errorRedirect, 51 | }); 52 | }); 53 | } catch (err) { 54 | return error(500, err.message); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /firebase/firestore-security-rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | function isSignedIn(){ 5 | return request.auth != null && 6 | request.auth.uid != null && 7 | request.auth.token != null; 8 | } 9 | 10 | function getUnchangedKeys(){ 11 | return request.resource.data.diff(resource.data).unchangedKeys(); 12 | } 13 | 14 | function getChangedKeys(){ 15 | return request.resource.data.diff(resource.data).changedKeys(); 16 | } 17 | 18 | match /users/{userId} { 19 | allow read: if isSignedIn(); 20 | allow create: if isSignedIn() && 21 | request.resource.data.uid == userId && 22 | request.auth.uid == userId; 23 | allow update: if isSignedIn() && 24 | userId == request.auth.uid && 25 | !getChangedKeys().hasAny([ 26 | "nTransactions", 27 | "totalTransactionAmount", 28 | "totalAmountRepaid", 29 | "disabled" 30 | ]) 31 | } 32 | 33 | match /transactions/{transactionId} { 34 | allow read: if isSignedIn() && resource.data.user == request.auth.uid; 35 | // User can't create or update transactions. 36 | } 37 | 38 | match /merchants/{merchantId} { 39 | allow get: if isSignedIn(); 40 | allow read: if isSignedIn() && 41 | (request.auth.uid in resource.data.members || 42 | resource.data.createdBy == request.auth.uid); 43 | allow create: if isSignedIn() && 44 | request.resource.data.members.toSet().hasAll([request.auth.uid]) && 45 | request.resource.data.createdBy == request.auth.uid; 46 | allow update: if isSignedIn() && 47 | (resource.data.members.toSet().hasAll([request.auth.uid]) || 48 | resource.data.createdBy == request.auth.uid) && 49 | getUnchangedKeys().hasAll(["clientId", "clientSecret"]); 50 | } 51 | 52 | match /userbillsettlements/{settlementOrderId} { 53 | allow get: if true; 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /pages/api/confirmOrder.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | import { getMerchantById } from "../../API/ServerSideAPIs/merchants"; 5 | import { getOrder, confirmOrderForUser } from "../../API/ServerSideAPIs/orders"; 6 | 7 | import verifyIDToken from "../../helpers/verifyIDToken"; 8 | 9 | import { OrderDetails } from "../../types/order"; 10 | 11 | export default async function confirmOrder( 12 | req: NextApiRequest, 13 | res: NextApiResponse 14 | ) { 15 | const error = (status: number, message: string) => 16 | res.status(status).json({ 17 | error: message, 18 | message, 19 | }); 20 | 21 | try { 22 | const { authorization } = req.headers; 23 | const { orderId } = req.body as { orderId: string }; 24 | 25 | if (!orderId) return error(403, "Invalid credentials."); 26 | 27 | const decodedToken = await verifyIDToken(authorization); 28 | if (!decodedToken || !decodedToken.uid) 29 | return error(403, "Invalid credentials"); 30 | 31 | const order = (await getOrder(orderId)) as OrderDetails; 32 | if (!order || !order.merchant) return error(404, "Order Not Found"); 33 | 34 | const merchant = await getMerchantById(order.merchant); 35 | if (!merchant) return error(404, "Merchant Not Found"); 36 | 37 | if (order.status === "fulfilled") 38 | return error(400, "Order already fulfilled"); 39 | if (order.status === "declined") 40 | return error(400, "Order already declined"); 41 | 42 | // Do transaction related processing here. 43 | await confirmOrderForUser(orderId, order, decodedToken.uid, (err) => { 44 | if (err) return error(500, "Order could not be confirmed"); 45 | // Notify the merchant 46 | if (merchant.webhookURL) 47 | axios.post(merchant.webhookURL, { 48 | orderId, 49 | status: "confirmed", 50 | type: "order_confirmed", 51 | }); 52 | return res.status(200).json({ 53 | message: "Confirmed Order Successfully", 54 | redirectTo: merchant.errorRedirect, 55 | }); 56 | }); 57 | } catch (err) { 58 | return error(500, err.message); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pages/api/createBillSettlementTransaction.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import User from "../../types/user"; 4 | 5 | import razorpay from "../../helpers/razorpay"; 6 | import admin from "../../firebase/admin"; 7 | import verifyIDToken from "../../helpers/verifyIDToken"; 8 | 9 | export default async function createWalletAddMoneyTransaction( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | const error = (status: number, message: string) => 14 | res.status(status).json({ 15 | error: message, 16 | message, 17 | }); 18 | 19 | try { 20 | const { authorization } = req.headers; 21 | 22 | if (!authorization) return error(400, "Invalid credentials."); 23 | 24 | // Get user details from token. 25 | const decodedToken = await verifyIDToken(authorization); 26 | if (!decodedToken) return error(401, "Unauthorized"); 27 | 28 | const batch = admin.firestore().batch(); 29 | const userRef = admin.firestore().collection("users").doc(decodedToken.uid); 30 | 31 | const userData = (await userRef.get()).data() as User; 32 | if (!userData) return error(404, "User info not found."); 33 | 34 | const amount = 35 | Number(userData.totalTransactionAmount || 0) - 36 | Number(userData.totalAmountRepaid || 0); 37 | 38 | if (!amount) return error(400, "No billable amount pending"); 39 | 40 | const order = await razorpay.orders.create({ 41 | amount, 42 | currency: "INR", 43 | notes: { user: decodedToken.uid }, 44 | }); 45 | 46 | if (order) { 47 | const userBillSettlementRef = admin 48 | .firestore() 49 | .collection("userbillsettlements") 50 | .doc(order.id); 51 | batch.set(userBillSettlementRef, { 52 | ...order, 53 | user: decodedToken.uid, 54 | amount, 55 | createdAt: new Date(), 56 | updatedAt: new Date(), 57 | }); 58 | await batch.commit(); 59 | return res 60 | .status(201) 61 | .json({ message: "Created Settlement Order Successfully", order }); 62 | } 63 | return error(500, "Payment could not be created."); 64 | } catch (err) { 65 | console.log(err); 66 | return error(500, err.message); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pages/profile/[userId].tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import styled from "@emotion/styled"; 3 | 4 | import { 5 | Avatar, 6 | Heading, 7 | Text, 8 | Stat, 9 | StatNumber, 10 | StatHelpText, 11 | } from "@chakra-ui/react"; 12 | import { MdEmail, MdPhone } from "react-icons/md"; 13 | 14 | import setupProtectedRoute from "../../helpers/setupProtectedRoute"; 15 | import ContentWrapper from "../../components/Layout/ContentWrapper"; 16 | 17 | import useUser from "../../hooks/useUser"; 18 | 19 | const ProfileContentWrapper = styled(ContentWrapper)` 20 | padding: var(--standard-spacing); 21 | padding-top: calc(6 * var(--standard-spacing)); 22 | text-align: center; 23 | `; 24 | 25 | const UserProfile = () => { 26 | const user = useUser(); 27 | 28 | return ( 29 | <> 30 | 31 | Dashout - User Profile 32 | 33 | 34 | 40 | 41 | {user?.displayName || "Unnamed"} 42 | 43 | 44 | {user?.nTransactions || 0} 45 | Number Of Transactions 46 | 47 | 54 | 55 |  {user?.phoneNumber || "-"} 56 | 57 | 64 | 65 |  {user?.email || "-"} 66 | 67 | 68 | 69 | User Since {new Date(user?.createdAt || new Date()).toDateString()} 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | UserProfile.getInitialProps = setupProtectedRoute(); 77 | 78 | export default UserProfile; 79 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { useEffect } from "react"; 3 | import { useDisclosure } from "@chakra-ui/react"; 4 | import Cookie from "js-cookie"; 5 | 6 | import useStore from "../store/useStore"; 7 | 8 | import type User from "../types/user"; 9 | import auth, { getToken } from "../firebase/authentication"; 10 | import { saveUserProfileToFirestore } from "../API/auth"; 11 | 12 | import AppLayout from "../components/Layout"; 13 | 14 | const AppWrapper = ({ Component, pageProps }) => { 15 | const user = useStore((store) => store.user); 16 | const setUserInState = useStore((store) => store.setUser); 17 | const { 18 | isOpen: showLoginModal, 19 | onOpen: openLoginModal, 20 | onClose: closeLoginModal, 21 | } = useDisclosure(); 22 | 23 | const logoutUser = () => { 24 | Cookie.remove("accessToken"); 25 | setUserInState(null); 26 | if (auth.currentUser) auth.signOut(); 27 | }; 28 | 29 | useEffect(() => { 30 | auth.onAuthStateChanged(async (user) => { 31 | if (user) { 32 | // User is signed in. 33 | let userToSave: User = { 34 | displayName: user.displayName, 35 | email: user.email, 36 | emailVerified: user.emailVerified, 37 | photoURL: user.photoURL, 38 | phoneNumber: user.phoneNumber, 39 | uid: user.uid, 40 | providerInfo: JSON.parse(JSON.stringify(user.providerData)), 41 | createdAt: user.metadata.creationTime, 42 | lastSignIn: user.metadata.lastSignInTime, 43 | }; 44 | const accessToken = await getToken(false); 45 | saveUserProfileToFirestore( 46 | user.uid, 47 | userToSave, 48 | (_, dataFromFirestore) => { 49 | if (dataFromFirestore) { 50 | Cookie.set("accessToken", accessToken, { expires: 365 }); 51 | setUserInState(dataFromFirestore); 52 | } else logoutUser(); 53 | } 54 | ); 55 | } else logoutUser(); 56 | }); 57 | }, []); 58 | 59 | return ( 60 | <> 61 | 62 | 63 | 64 | 70 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default AppWrapper; 83 | -------------------------------------------------------------------------------- /API/merchants.ts: -------------------------------------------------------------------------------- 1 | import db, { firestore } from "../firebase/firestore"; 2 | import { v4 as uuid } from "uuid"; 3 | 4 | export const getMerchantsForUser = async ( 5 | userId: string, 6 | callback: (errorMessage: string, merchantsList: any[]) => any 7 | ) => { 8 | try { 9 | let merchantsRef = db 10 | .collection("merchants") 11 | .where("members", "array-contains", userId) 12 | .orderBy("createdAt", "desc"); 13 | return callback(null, (await merchantsRef.get()).docs); 14 | } catch (err) { 15 | console.log(err); 16 | return callback(err.message, []); 17 | } 18 | }; 19 | 20 | export const createMerchantForUser = async ( 21 | userId: string, 22 | merchantDetails: any, 23 | callback: (errorMessage: string, merchantInfo?: any) => any 24 | ) => { 25 | try { 26 | const merchantRef = db.collection("merchants").doc(); 27 | const userRef = db.collection("users").doc(userId); 28 | const batch = db.batch(); 29 | 30 | batch.set(merchantRef, { 31 | ...merchantDetails, 32 | createdAt: firestore.FieldValue.serverTimestamp(), 33 | updatedAt: firestore.FieldValue.serverTimestamp(), 34 | clientId: uuid(), 35 | clientSecret: uuid(), 36 | nMembers: 1, 37 | members: [userId], 38 | createdBy: userId, 39 | }); 40 | batch.update(userRef, { 41 | nMerchants: firestore.FieldValue.increment(1), 42 | updatedAt: firestore.FieldValue.serverTimestamp(), 43 | merchants: firestore.FieldValue.arrayUnion(merchantRef.id), 44 | }); 45 | 46 | await batch.commit(); 47 | 48 | return callback(null, (await merchantRef.get()).data()); 49 | } catch (err) { 50 | console.log(err); 51 | return callback(err.message, null); 52 | } 53 | }; 54 | 55 | export const addUserToMerchant = async ( 56 | userId: string, 57 | merchantId: string, 58 | callback: (errorMessage: string, updatedMerchantInfo?: any) => any 59 | ) => { 60 | try { 61 | const merchantRef = db.collection("merchants").doc(merchantId); 62 | const userRef = db.collection("users").doc(userId); 63 | const batch = db.batch(); 64 | 65 | batch.set(merchantRef, { 66 | updatedAt: firestore.FieldValue.serverTimestamp(), 67 | nMembers: firestore.FieldValue.increment(1), 68 | members: firestore.FieldValue.arrayUnion(userId), 69 | }); 70 | batch.update(userRef, { 71 | nMerchants: firestore.FieldValue.increment(1), 72 | updatedAt: firestore.FieldValue.serverTimestamp(), 73 | merchants: firestore.FieldValue.arrayUnion(merchantRef.id), 74 | }); 75 | 76 | await batch.commit(); 77 | 78 | return callback(null, (await merchantRef.get()).data()); 79 | } catch (err) { 80 | console.log(err); 81 | return callback(err.message, null); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { Box, Image, Stack, Text, Heading, HStack } from "@chakra-ui/react"; 4 | import styled from "@emotion/styled"; 5 | 6 | import { MdOutlineArrowForward, MdOutlineShowChart } from "react-icons/md"; 7 | import { FaUserCircle } from "react-icons/fa"; 8 | 9 | import Button from "../components/Button"; 10 | 11 | const HomePageHero = styled(Stack)` 12 | @media (min-width: 769px) { 13 | flex-flow: row; 14 | max-height: 90vh; 15 | overflow: hidden; 16 | } 17 | 18 | .content { 19 | width: 80%; 20 | display: flex; 21 | justify-content: center; 22 | flex-flow: column; 23 | padding: var(--standard-spacing); 24 | max-width: 500px; 25 | margin: 0 auto; 26 | 27 | @media (max-width: 768px) { 28 | padding: calc(4.5 * var(--standard-spacing)) var(--standard-spacing); 29 | } 30 | } 31 | 32 | .heroimage { 33 | object-fit: cover; 34 | max-height: 100%; 35 | max-width: 100%; 36 | min-width: 100%; 37 | object-position: bottom; 38 | 39 | &-container { 40 | max-height: 50vh; 41 | overflow: hidden; 42 | 43 | @media (min-width: 769px) { 44 | width: 45%; 45 | min-height: 135vh; 46 | margin: 0; 47 | transform: rotate(15deg) translateX(5%) translateY(-10%); 48 | } 49 | } 50 | } 51 | `; 52 | 53 | const HomePage = ({ openLoginModal, isLoggedIn, user }) => ( 54 | <> 55 | 56 | Dashout - Buy Now Pay Later 57 | 58 | 59 | 60 | 61 | Dash Out. 62 |
63 | At Checkout! 64 |
65 | 66 | With 0 Fees. 0 Interest. 67 |
68 | It truly is the next gen of shopping experience. 69 |
70 | 71 | {isLoggedIn ? ( 72 | <> 73 | 74 | 75 | 78 | 79 | 80 | 81 | 82 | 89 | 90 | 91 | 92 | ) : ( 93 | <> 94 | 100 | 107 | 108 | )} 109 | 110 |
111 | 112 | Purchase 118 | 119 |
120 | 121 | ); 122 | 123 | export default HomePage; 124 | -------------------------------------------------------------------------------- /pages/api/settleUserBill.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { createHmac } from "crypto"; 3 | 4 | import razorpay from "../../helpers/razorpay"; 5 | import admin from "../../firebase/admin"; 6 | import verifyIDToken from "../../helpers/verifyIDToken"; 7 | 8 | export default async function settleUserBillAPI( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const error = (status: number, message: string) => 13 | res.status(status).json({ 14 | error: message, 15 | message, 16 | }); 17 | 18 | try { 19 | const { 20 | status = "successful", 21 | razorpay_payment_id, 22 | razorpay_order_id, 23 | razorpay_signature, 24 | razorpay_error, 25 | } = req.body; 26 | const { authorization } = req.headers; 27 | 28 | if (!razorpay_order_id || !authorization) 29 | return error(400, "Incomplete Information"); 30 | 31 | // Get user details from token. 32 | const decodedToken = await verifyIDToken(authorization); 33 | if (!decodedToken) return error(401, "Unauthorized"); 34 | 35 | const userBillSettlementRef = admin 36 | .firestore() 37 | .collection("userbillsettlements") 38 | .doc(razorpay_order_id); 39 | 40 | const userBillSettlementData = (await userBillSettlementRef.get()).data(); 41 | 42 | if ( 43 | !userBillSettlementData || 44 | userBillSettlementData.user !== decodedToken.uid 45 | ) 46 | return error(404, "Bill Settment Record not found."); 47 | else if (userBillSettlementData.status !== "created") 48 | return error(400, "Payment has already been processed."); 49 | 50 | const batch = admin.firestore().batch(); 51 | 52 | const userRef = admin.firestore().collection("users").doc(decodedToken.uid); 53 | 54 | const userData = (await userRef.get()).data(); 55 | if (!userData) return error(404, "User info not found."); 56 | 57 | const razorpayOrder = await razorpay.orders.fetch(razorpay_order_id); 58 | if (!razorpayOrder) return error(404, "Razorpay order info not found."); 59 | 60 | if (status === "successful") { 61 | if (!razorpay_payment_id || !razorpay_signature) 62 | return error(400, "Incomplete Information"); 63 | 64 | // Verifying signature 65 | const generatedSignature = createHmac( 66 | "sha256", 67 | process.env.RAZORPAY_KEY_SECRET 68 | ) 69 | .update(razorpay_order_id + "|" + razorpay_payment_id) 70 | .digest("hex"); 71 | 72 | if (generatedSignature != razorpay_signature) 73 | return error(403, "Unauthorized"); 74 | 75 | const billSettlementPayments = await razorpay.orders.fetchPayments( 76 | razorpay_order_id 77 | ); 78 | batch.update(userBillSettlementRef, { 79 | ...razorpayOrder, 80 | status: "paid", 81 | amount_due: 0, 82 | payments: billSettlementPayments || [], 83 | updatedAt: new Date(), 84 | }); 85 | batch.update(userRef, { 86 | totalAmountRepaid: userData.totalTransactionAmount, 87 | updatedAt: new Date(), 88 | }); 89 | } else { 90 | // Payment failure setting. 91 | if (!razorpay_error) return error(400, "Incomplete Information"); 92 | batch.update(userBillSettlementRef, { 93 | ...razorpayOrder, 94 | payments: [], 95 | updatedAt: new Date(), 96 | }); 97 | } 98 | 99 | await batch.commit(); 100 | return res.status(200).json({ message: "Updated user bill successfully." }); 101 | } catch (err) { 102 | return error(500, err.message); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /components/Merchants/MerchantCreatorModal.tsx: -------------------------------------------------------------------------------- 1 | import { SyntheticEvent, useRef } from "react"; 2 | import { 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalFooter, 8 | ModalBody, 9 | ModalCloseButton, 10 | Button, 11 | } from "@chakra-ui/react"; 12 | import FormControl from "../FormControl"; 13 | import Merchant from "../../types/merchant"; 14 | 15 | interface MerchantCreatorModalProps { 16 | isOpen: boolean; 17 | onClose: () => any; 18 | onSubmit: (formData: Merchant) => any; 19 | isLoading?: boolean; 20 | } 21 | 22 | const MerchantCreatorModal = ({ 23 | isOpen = false, 24 | isLoading = false, 25 | onClose, 26 | onSubmit, 27 | }: MerchantCreatorModalProps) => { 28 | const formRef = useRef(null); 29 | 30 | const submitForm = (e: SyntheticEvent) => { 31 | e.preventDefault(); 32 | const formData = Object.fromEntries( 33 | new FormData(formRef.current) 34 | ) as unknown as Merchant; 35 | onSubmit(formData); 36 | }; 37 | 38 | return ( 39 | 40 | 41 |
42 | 43 | Create Merchant 44 | 45 | 46 | 54 |
55 | 64 |
65 | 74 |
75 | 84 |
85 | 94 |
95 | 104 |
105 | 106 | 107 | 116 | 119 | 120 |
121 |
122 |
123 | ); 124 | }; 125 | 126 | export default MerchantCreatorModal; 127 | -------------------------------------------------------------------------------- /components/Layout/Header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { 3 | Box, 4 | Stack, 5 | Image, 6 | IconButton, 7 | ButtonGroup, 8 | HStack, 9 | } from "@chakra-ui/react"; 10 | import { useColorMode } from "@chakra-ui/color-mode"; 11 | import styled from "@emotion/styled"; 12 | import { 13 | MdAccountCircle as LoginIcon, 14 | MdLogout as LogoutIcon, 15 | } from "react-icons/md"; 16 | import { FaMoon, FaSun, FaUserCircle, FaWallet } from "react-icons/fa"; 17 | 18 | import Button from "../Button"; 19 | 20 | import useStore from "../../store/useStore"; 21 | 22 | const AppHeader = styled(Box)` 23 | position: fixed; 24 | border-bottom: 0.075rem solid var(--backgroundgrey); 25 | padding: var(--mini-spacing); 26 | z-index: 101; 27 | background: var(--white); 28 | `; 29 | 30 | const Logo = styled(Image)` 31 | max-height: 32px; 32 | `; 33 | 34 | const Container = styled(Stack)` 35 | max-width: 1100px; 36 | margin: 0 auto; 37 | `; 38 | 39 | const Left = styled.div` 40 | width: 30%; 41 | `; 42 | 43 | const Right = styled(Left)` 44 | width: 70%; 45 | text-align: right; 46 | `; 47 | 48 | const Header = ({ openLoginModal = () => null, logoutUser = () => null }) => { 49 | const { toggleColorMode } = useColorMode(); 50 | 51 | const stateUser = useStore((state) => state.user); 52 | const isDarkModeActive = useStore((store) => store.isDarkModeActive); 53 | const toggleDarkMode = useStore((store) => store.toggleDarkMode); 54 | 55 | const toggleDarkModeForApp = () => { 56 | toggleColorMode(); 57 | toggleDarkMode(); // Store dark mode in global state as well. 58 | }; 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {typeof window !== "undefined" && ( 72 | 73 | 79 | {isDarkModeActive ? : } 80 | 81 | {!stateUser ? ( 82 | 93 | ) : ( 94 | 95 | 96 | 97 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 113 | 114 | 115 | 116 | 117 | 134 | 135 | )} 136 | 137 | )} 138 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | export default Header; 145 | -------------------------------------------------------------------------------- /pages/repayment/[orderId].tsx: -------------------------------------------------------------------------------- 1 | /* Dedicated Page to make repayments for users. */ 2 | import { useRouter } from "next/router"; 3 | import { useState } from "react"; 4 | import Script from "next/script"; 5 | import styled from "@emotion/styled"; 6 | import { Container, Text, Image } from "@chakra-ui/react"; 7 | 8 | import Error from "../../components/Layout/Error"; 9 | 10 | import db from "../../firebase/firestore"; 11 | 12 | import useStore from "../../store/useStore"; 13 | 14 | import setupProtectedRoute from "../../helpers/setupProtectedRoute"; 15 | import toasts from "../../helpers/toasts"; 16 | import { verifyRepayment } from "../../API/repayments"; 17 | import Head from "next/head"; 18 | 19 | const WalletImage = styled(Image)` 20 | max-width: 45vw; 21 | `; 22 | 23 | const MakeWalletPayment = ({ error, orderInfo, transactionInfo }) => { 24 | const router = useRouter(); 25 | 26 | const user = useStore((state) => state.user); 27 | 28 | const [errorMessage, setErrorMessage] = useState(error); 29 | const [transactionState, setTransactionState] = useState("not-started"); 30 | 31 | function initializePayment() { 32 | if ( 33 | orderInfo?.status !== "created" || 34 | transactionInfo?.status === "paid" || 35 | transactionInfo?.status === "failed" 36 | ) 37 | return setErrorMessage( 38 | "Payment process has already taken place. Please check back in some time." 39 | ); 40 | if (user.uid !== orderInfo.user) return setErrorMessage("Unauthorized"); 41 | 42 | const options = { 43 | key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, 44 | amount: orderInfo.amount, 45 | currency: orderInfo.currency, 46 | name: "Dashout", 47 | description: "Dashout Repayment Transaction", 48 | image: "/logo-512.png", 49 | order_id: orderInfo.id, 50 | handler: (response) => 51 | verifyRepayment(response, (error) => { 52 | if (error) { 53 | setTransactionState("failed"); 54 | setErrorMessage(error); 55 | return toasts.error(error); 56 | } 57 | setTransactionState("successful"); 58 | toasts.success("Repayment Successful"); 59 | window.location.replace("/user/tab"); 60 | }), 61 | prefill: { 62 | name: user?.displayName || "", 63 | email: user?.email || "", 64 | contact: user?.phoneNumber || "", 65 | }, 66 | notes: {}, 67 | theme: { 68 | color: "#008080", 69 | }, 70 | }; 71 | const razorpayPaymentInstance = new globalThis.Razorpay(options); 72 | razorpayPaymentInstance.open(); 73 | setTransactionState("started"); 74 | } 75 | 76 | return ( 77 | <> 78 | 79 | Dashout - Repay Bill 80 | 81 | {errorMessage ? ( 82 | 83 | ) : ( 84 | 85 | 90 |
91 | 92 | {transactionState === "not-started" 93 | ? "Transaction starting" 94 | : transactionState === "started" 95 | ? "Transaction In Progress" 96 | : transactionState === "successful" 97 | ? "Transaction Successful. Your Balance will reflect in your wallet soon." 98 | : "Transaction Failed"} 99 | 100 |