Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/nivindulakshitha/horizon

Horizon is a financial SaaS platform that connects to multiple bank accounts, displays transactions in real-time, allows users to transfer money to other platform users, and manages their finances altogether.
https://github.com/nivindulakshitha/horizon

appwrite nextjs saas typescript

Last synced: about 2 months ago
JSON representation

Horizon is a financial SaaS platform that connects to multiple bank accounts, displays transactions in real-time, allows users to transfer money to other platform users, and manages their finances altogether.

Awesome Lists containing this project

README

        





Project Banner





nextdotjs
typescript
tailwindcss
appwrite

A Fintech Bank Application


Build this project step by step with the detailed tutorial on JavaScript Mastery YouTube. Join the JSM family!

## πŸ“‹ Table of Contents

1. πŸ€– [Introduction](#introduction)
2. βš™οΈ [Tech Stack](#tech-stack)
3. πŸ”‹ [Features](#features)
4. 🀸 [Quick Start](#quick-start)
5. πŸ•ΈοΈ [Code Snippets to Copy](#snippets)
6. πŸ”— [Assets](#links)
7. πŸš€ [More](#more)

## πŸ€– Introduction

Built with Next.js, Horizon is a financial SaaS platform that connects to multiple bank accounts, displays transactions in real-time, allows users to transfer money to other platform users, and manages their finances altogether.

## βš™οΈ Tech Stack

- Next.js
- TypeScript
- Appwrite
- Plaid
- Dwolla
- React Hook Form
- Zod
- TailwindCSS
- Chart.js
- ShadCN

## πŸ”‹ Features

πŸ‘‰ **Authentication**: An ultra-secure SSR authentication with proper validations and authorization

πŸ‘‰ **Connect Banks**: Integrates with Plaid for multiple bank account linking

πŸ‘‰ **Home Page**: Shows general overview of user account with total balance from all connected banks, recent transactions, money spent on different categories, etc

πŸ‘‰ **My Banks**: Check the complete list of all connected banks with respective balances, account details

πŸ‘‰ **Transaction History**: Includes pagination and filtering options for viewing transaction history of different banks

πŸ‘‰ **Real-time Updates**: Reflects changes across all relevant pages upon connecting new bank accounts.

πŸ‘‰ **Funds Transfer**: Allows users to transfer funds using Dwolla to other accounts with required fields and recipient bank ID.

πŸ‘‰ **Responsiveness**: Ensures the application adapts seamlessly to various screen sizes and devices, providing a consistent user experience across desktop, tablet, and mobile platforms.

and many more, including code architecture and reusability.

## 🀸 Quick Start

Follow these steps to set up the project locally on your machine.

**Prerequisites**

Make sure you have the following installed on your machine:

- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org/en)
- [npm](https://www.npmjs.com/) (Node Package Manager)

**Cloning the Repository**

```bash
git clone https://github.com/nivindulakshitha/Horizon.git
cd Horizon
```

**Installation**

Install the project dependencies using npm:

```bash
npm install
```

**Set Up Environment Variables**

Create a new file named `.env` in the root of your project and add the following content:

```env
#NEXT
NEXT_PUBLIC_SITE_URL=

#APPWRITE
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT=
APPWRITE_DATABASE_ID=
APPWRITE_USER_COLLECTION_ID=
APPWRITE_BANK_COLLECTION_ID=
APPWRITE_TRANSACTION_COLLECTION_ID=
NEXT_APPWRITE_KEY=

#PLAID
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=
PLAID_PRODUCTS=
PLAID_COUNTRY_CODES=

#DWOLLA
DWOLLA_KEY=
DWOLLA_SECRET=
DWOLLA_BASE_URL=https://api-sandbox.dwolla.com
DWOLLA_ENV=sandbox

```

Replace the placeholder values with your actual respective account credentials. You can obtain these credentials by signing up on the [Appwrite](https://appwrite.io/), [Plaid](https://plaid.com/) and [Dwolla](https://www.dwolla.com/)

**Running the Project**

```bash
npm run dev
```

Open [http://localhost:3000](http://localhost:3000) in your browser to view the project.

## πŸ•ΈοΈ Snippets

.env.example

```env
#NEXT
NEXT_PUBLIC_SITE_URL=

#APPWRITE
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT=
APPWRITE_DATABASE_ID=
APPWRITE_USER_COLLECTION_ID=
APPWRITE_BANK_COLLECTION_ID=
APPWRITE_TRANSACTION_COLLECTION_ID=
APPWRITE_SECRET=

#PLAID
PLAID_CLIENT_ID=
PLAID_SECRET=
PLAID_ENV=sandbox
PLAID_PRODUCTS=auth,transactions,identity
PLAID_COUNTRY_CODES=US,CA

#DWOLLA
DWOLLA_KEY=
DWOLLA_SECRET=
DWOLLA_BASE_URL=https://api-sandbox.dwolla.com
DWOLLA_ENV=sandbox
```

exchangePublicToken

```typescript
// This function exchanges a public token for an access token and item ID
export const exchangePublicToken = async ({
publicToken,
user,
}: exchangePublicTokenProps) => {
try {
// Exchange public token for access token and item ID
const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});

const accessToken = response.data.access_token;
const itemId = response.data.item_id;

// Get account information from Plaid using the access token
const accountsResponse = await plaidClient.accountsGet({
access_token: accessToken,
});

const accountData = accountsResponse.data.accounts[0];

// Create a processor token for Dwolla using the access token and account ID
const request: ProcessorTokenCreateRequest = {
access_token: accessToken,
account_id: accountData.account_id,
processor: "dwolla" as ProcessorTokenCreateRequestProcessorEnum,
};

const processorTokenResponse =
await plaidClient.processorTokenCreate(request);
const processorToken = processorTokenResponse.data.processor_token;

// Create a funding source URL for the account using the Dwolla customer ID, processor token, and bank name
const fundingSourceUrl = await addFundingSource({
dwollaCustomerId: user.dwollaCustomerId,
processorToken,
bankName: accountData.name,
});

// If the funding source URL is not created, throw an error
if (!fundingSourceUrl) throw Error;

// Create a bank account using the user ID, item ID, account ID, access token, funding source URL, and sharable ID
await createBankAccount({
userId: user.$id,
bankId: itemId,
accountId: accountData.account_id,
accessToken,
fundingSourceUrl,
sharableId: encryptId(accountData.account_id),
});

// Revalidate the path to reflect the changes
revalidatePath("/");

// Return a success message
return parseStringify({
publicTokenExchange: "complete",
});
} catch (error) {
// Log any errors that occur during the process
console.error("An error occurred while creating exchanging token:", error);
}
};
```

user.actions.ts

```typescript
"use server";

import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { ID, Query } from "node-appwrite";
import {
CountryCode,
ProcessorTokenCreateRequest,
ProcessorTokenCreateRequestProcessorEnum,
Products,
} from "plaid";

import { plaidClient } from "@/lib/plaid.config";
import {
parseStringify,
extractCustomerIdFromUrl,
encryptId,
} from "@/lib/utils";

import { createAdminClient, createSessionClient } from "../appwrite.config";

import { addFundingSource, createDwollaCustomer } from "./dwolla.actions";

const {
APPWRITE_DATABASE_ID: DATABASE_ID,
APPWRITE_USER_COLLECTION_ID: USER_COLLECTION_ID,
APPWRITE_BANK_COLLECTION_ID: BANK_COLLECTION_ID,
} = process.env;

export const signUp = async ({ password, ...userData }: SignUpParams) => {
let newUserAccount;

try {
// create appwrite user
const { database, account } = await createAdminClient();
newUserAccount = await account.create(
ID.unique(),
userData.email,
password,
`${userData.firstName} ${userData.lastName}`
);

if (!newUserAccount) throw new Error("Error creating user");

// create dwolla customer
const dwollaCustomerUrl = await createDwollaCustomer({
...userData,
type: "personal",
});

if (!dwollaCustomerUrl) throw new Error("Error creating dwolla customer");
const dwollaCustomerId = extractCustomerIdFromUrl(dwollaCustomerUrl);

const newUser = await database.createDocument(
DATABASE_ID!,
USER_COLLECTION_ID!,
ID.unique(),
{
...userData,
userId: newUserAccount.$id,
dwollaCustomerUrl,
dwollaCustomerId,
}
);

const session = await account.createEmailPasswordSession(
userData.email,
password
);

cookies().set("appwrite-session", session.secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: true,
});

return parseStringify(newUser);
} catch (error) {
console.error("Error", error);

// check if account has been created, if so, delete it
if (newUserAccount?.$id) {
const { user } = await createAdminClient();
await user.delete(newUserAccount?.$id);
}

return null;
}
};

export const signIn = async ({ email, password }: signInProps) => {
try {
const { account } = await createAdminClient();
const session = await account.createEmailPasswordSession(email, password);

cookies().set("appwrite-session", session.secret, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: true,
});

const user = await getUserInfo({ userId: session.userId });

return parseStringify(user);
} catch (error) {
console.error("Error", error);
return null;
}
};

export const getLoggedInUser = async () => {
try {
const { account } = await createSessionClient();
const result = await account.get();

const user = await getUserInfo({ userId: result.$id });

return parseStringify(user);
} catch (error) {
console.error("Error", error);
return null;
}
};

// CREATE PLAID LINK TOKEN
export const createLinkToken = async (user: User) => {
try {
const tokeParams = {
user: {
client_user_id: user.$id,
},
client_name: user.firstName + user.lastName,
products: ["auth"] as Products[],
language: "en",
country_codes: ["US"] as CountryCode[],
};

const response = await plaidClient.linkTokenCreate(tokeParams);

return parseStringify({ linkToken: response.data.link_token });
} catch (error) {
console.error(
"An error occurred while creating a new Horizon user:",
error
);
}
};

// EXCHANGE PLAID PUBLIC TOKEN
// This function exchanges a public token for an access token and item ID
export const exchangePublicToken = async ({
publicToken,
user,
}: exchangePublicTokenProps) => {
try {
// Exchange public token for access token and item ID
const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});

const accessToken = response.data.access_token;
const itemId = response.data.item_id;

// Get account information from Plaid using the access token
const accountsResponse = await plaidClient.accountsGet({
access_token: accessToken,
});

const accountData = accountsResponse.data.accounts[0];

// Create a processor token for Dwolla using the access token and account ID
const request: ProcessorTokenCreateRequest = {
access_token: accessToken,
account_id: accountData.account_id,
processor: "dwolla" as ProcessorTokenCreateRequestProcessorEnum,
};

const processorTokenResponse =
await plaidClient.processorTokenCreate(request);
const processorToken = processorTokenResponse.data.processor_token;

// Create a funding source URL for the account using the Dwolla customer ID, processor token, and bank name
const fundingSourceUrl = await addFundingSource({
dwollaCustomerId: user.dwollaCustomerId,
processorToken,
bankName: accountData.name,
});

// If the funding source URL is not created, throw an error
if (!fundingSourceUrl) throw Error;

// Create a bank account using the user ID, item ID, account ID, access token, funding source URL, and sharable ID
await createBankAccount({
userId: user.$id,
bankId: itemId,
accountId: accountData.account_id,
accessToken,
fundingSourceUrl,
sharableId: encryptId(accountData.account_id),
});

// Revalidate the path to reflect the changes
revalidatePath("/");

// Return a success message
return parseStringify({
publicTokenExchange: "complete",
});
} catch (error) {
// Log any errors that occur during the process
console.error("An error occurred while creating exchanging token:", error);
}
};

export const getUserInfo = async ({ userId }: getUserInfoProps) => {
try {
const { database } = await createAdminClient();

const user = await database.listDocuments(
DATABASE_ID!,
USER_COLLECTION_ID!,
[Query.equal("userId", [userId])]
);

if (user.total !== 1) return null;

return parseStringify(user.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};

export const createBankAccount = async ({
accessToken,
userId,
accountId,
bankId,
fundingSourceUrl,
sharableId,
}: createBankAccountProps) => {
try {
const { database } = await createAdminClient();

const bankAccount = await database.createDocument(
DATABASE_ID!,
BANK_COLLECTION_ID!,
ID.unique(),
{
accessToken,
userId,
accountId,
bankId,
fundingSourceUrl,
sharableId,
}
);

return parseStringify(bankAccount);
} catch (error) {
console.error("Error", error);
return null;
}
};

// get user bank accounts
export const getBanks = async ({ userId }: getBanksProps) => {
try {
const { database } = await createAdminClient();

const banks = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("userId", [userId])]
);

return parseStringify(banks.documents);
} catch (error) {
console.error("Error", error);
return null;
}
};

// get specific bank from bank collection by document id
export const getBank = async ({ documentId }: getBankProps) => {
try {
const { database } = await createAdminClient();

const bank = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("$id", [documentId])]
);

if (bank.total !== 1) return null;

return parseStringify(bank.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};

// get specific bank from bank collection by account id
export const getBankByAccountId = async ({
accountId,
}: getBankByAccountIdProps) => {
try {
const { database } = await createAdminClient();

const bank = await database.listDocuments(
DATABASE_ID!,
BANK_COLLECTION_ID!,
[Query.equal("accountId", [accountId])]
);

if (bank.total !== 1) return null;

return parseStringify(bank.documents[0]);
} catch (error) {
console.error("Error", error);
return null;
}
};
```

dwolla.actions.ts

```typescript
"use server";

import { Client } from "dwolla-v2";

const getEnvironment = (): "production" | "sandbox" => {
const environment = process.env.DWOLLA_ENV as string;

switch (environment) {
case "sandbox":
return "sandbox";
case "production":
return "production";
default:
throw new Error(
"Dwolla environment should either be set to `sandbox` or `production`"
);
}
};

const dwollaClient = new Client({
environment: getEnvironment(),
key: process.env.DWOLLA_KEY as string,
secret: process.env.DWOLLA_SECRET as string,
});

// Create a Dwolla Funding Source using a Plaid Processor Token
export const createFundingSource = async (
options: CreateFundingSourceOptions
) => {
try {
return await dwollaClient
.post(`customers/${options.customerId}/funding-sources`, {
name: options.fundingSourceName,
plaidToken: options.plaidToken,
})
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Creating a Funding Source Failed: ", err);
}
};

export const createOnDemandAuthorization = async () => {
try {
const onDemandAuthorization = await dwollaClient.post(
"on-demand-authorizations"
);
const authLink = onDemandAuthorization.body._links;
return authLink;
} catch (err) {
console.error("Creating an On Demand Authorization Failed: ", err);
}
};

export const createDwollaCustomer = async (
newCustomer: NewDwollaCustomerParams
) => {
try {
return await dwollaClient
.post("customers", newCustomer)
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Creating a Dwolla Customer Failed: ", err);
}
};

export const createTransfer = async ({
sourceFundingSourceUrl,
destinationFundingSourceUrl,
amount,
}: TransferParams) => {
try {
const requestBody = {
_links: {
source: {
href: sourceFundingSourceUrl,
},
destination: {
href: destinationFundingSourceUrl,
},
},
amount: {
currency: "USD",
value: amount,
},
};
return await dwollaClient
.post("transfers", requestBody)
.then((res) => res.headers.get("location"));
} catch (err) {
console.error("Transfer fund failed: ", err);
}
};

export const addFundingSource = async ({
dwollaCustomerId,
processorToken,
bankName,
}: AddFundingSourceParams) => {
try {
// create dwolla auth link
const dwollaAuthLinks = await createOnDemandAuthorization();

// add funding source to the dwolla customer & get the funding source url
const fundingSourceOptions = {
customerId: dwollaCustomerId,
fundingSourceName: bankName,
plaidToken: processorToken,
_links: dwollaAuthLinks,
};
return await createFundingSource(fundingSourceOptions);
} catch (err) {
console.error("Transfer fund failed: ", err);
}
};
```

bank.actions.ts

```typescript
"use server";

import {
ACHClass,
CountryCode,
TransferAuthorizationCreateRequest,
TransferCreateRequest,
TransferNetwork,
TransferType,
} from "plaid";

import { plaidClient } from "../plaid.config";
import { parseStringify } from "../utils";

import { getTransactionsByBankId } from "./transaction.actions";
import { getBanks, getBank } from "./user.actions";

// Get multiple bank accounts
export const getAccounts = async ({ userId }: getAccountsProps) => {
try {
// get banks from db
const banks = await getBanks({ userId });

const accounts = await Promise.all(
banks?.map(async (bank: Bank) => {
// get each account info from plaid
const accountsResponse = await plaidClient.accountsGet({
access_token: bank.accessToken,
});
const accountData = accountsResponse.data.accounts[0];

// get institution info from plaid
const institution = await getInstitution({
institutionId: accountsResponse.data.item.institution_id!,
});

const account = {
id: accountData.account_id,
availableBalance: accountData.balances.available!,
currentBalance: accountData.balances.current!,
institutionId: institution.institution_id,
name: accountData.name,
officialName: accountData.official_name,
mask: accountData.mask!,
type: accountData.type as string,
subtype: accountData.subtype! as string,
appwriteItemId: bank.$id,
sharableId: bank.sharableId,
};

return account;
})
);

const totalBanks = accounts.length;
const totalCurrentBalance = accounts.reduce((total, account) => {
return total + account.currentBalance;
}, 0);

return parseStringify({ data: accounts, totalBanks, totalCurrentBalance });
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};

// Get one bank account
export const getAccount = async ({ appwriteItemId }: getAccountProps) => {
try {
// get bank from db
const bank = await getBank({ documentId: appwriteItemId });

// get account info from plaid
const accountsResponse = await plaidClient.accountsGet({
access_token: bank.accessToken,
});
const accountData = accountsResponse.data.accounts[0];

// get transfer transactions from appwrite
const transferTransactionsData = await getTransactionsByBankId({
bankId: bank.$id,
});

const transferTransactions = transferTransactionsData.documents.map(
(transferData: Transaction) => ({
id: transferData.$id,
name: transferData.name!,
amount: transferData.amount!,
date: transferData.$createdAt,
paymentChannel: transferData.channel,
category: transferData.category,
type: transferData.senderBankId === bank.$id ? "debit" : "credit",
})
);

// get institution info from plaid
const institution = await getInstitution({
institutionId: accountsResponse.data.item.institution_id!,
});

const transactions = await getTransactions({
accessToken: bank?.accessToken,
});

const account = {
id: accountData.account_id,
availableBalance: accountData.balances.available!,
currentBalance: accountData.balances.current!,
institutionId: institution.institution_id,
name: accountData.name,
officialName: accountData.official_name,
mask: accountData.mask!,
type: accountData.type as string,
subtype: accountData.subtype! as string,
appwriteItemId: bank.$id,
};

// sort transactions by date such that the most recent transaction is first
const allTransactions = [...transactions, ...transferTransactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);

return parseStringify({
data: account,
transactions: allTransactions,
});
} catch (error) {
console.error("An error occurred while getting the account:", error);
}
};

// Get bank info
export const getInstitution = async ({
institutionId,
}: getInstitutionProps) => {
try {
const institutionResponse = await plaidClient.institutionsGetById({
institution_id: institutionId,
country_codes: ["US"] as CountryCode[],
});

const intitution = institutionResponse.data.institution;

return parseStringify(intitution);
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};

// Get transactions
export const getTransactions = async ({
accessToken,
}: getTransactionsProps) => {
let hasMore = true;
let transactions: any = [];

try {
// Iterate through each page of new transaction updates for item
while (hasMore) {
const response = await plaidClient.transactionsSync({
access_token: accessToken,
});

const data = response.data;

transactions = response.data.added.map((transaction) => ({
id: transaction.transaction_id,
name: transaction.name,
paymentChannel: transaction.payment_channel,
type: transaction.payment_channel,
accountId: transaction.account_id,
amount: transaction.amount,
pending: transaction.pending,
category: transaction.category ? transaction.category[0] : "",
date: transaction.date,
image: transaction.logo_url,
}));

hasMore = data.has_more;
}

return parseStringify(transactions);
} catch (error) {
console.error("An error occurred while getting the accounts:", error);
}
};

// Create Transfer
export const createTransfer = async () => {
const transferAuthRequest: TransferAuthorizationCreateRequest = {
access_token: "access-sandbox-cddd20c1-5ba8-4193-89f9-3a0b91034c25",
account_id: "Zl8GWV1jqdTgjoKnxQn1HBxxVBanm5FxZpnQk",
funding_account_id: "442d857f-fe69-4de2-a550-0c19dc4af467",
type: "credit" as TransferType,
network: "ach" as TransferNetwork,
amount: "10.00",
ach_class: "ppd" as ACHClass,
user: {
legal_name: "Anne Charleston",
},
};
try {
const transferAuthResponse =
await plaidClient.transferAuthorizationCreate(transferAuthRequest);
const authorizationId = transferAuthResponse.data.authorization.id;

const transferCreateRequest: TransferCreateRequest = {
access_token: "access-sandbox-cddd20c1-5ba8-4193-89f9-3a0b91034c25",
account_id: "Zl8GWV1jqdTgjoKnxQn1HBxxVBanm5FxZpnQk",
description: "payment",
authorization_id: authorizationId,
};

const responseCreateResponse = await plaidClient.transferCreate(
transferCreateRequest
);

const transfer = responseCreateResponse.data.transfer;
return parseStringify(transfer);
} catch (error) {
console.error(
"An error occurred while creating transfer authorization:",
error
);
}
};
```

BankTabItem.tsx

```typescript
"use client";

import { useSearchParams, useRouter } from "next/navigation";

import { cn, formUrlQuery } from "@/lib/utils";

export const BankTabItem = ({ account, appwriteItemId }: BankTabItemProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const isActive = appwriteItemId === account?.appwriteItemId;

const handleBankChange = () => {
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: account?.appwriteItemId,
});
router.push(newUrl, { scroll: false });
};

return (



{account.name}



);
};
```

BankInfo.tsx

```typescript
"use client";

import Image from "next/image";
import { useSearchParams, useRouter } from "next/navigation";

import {
cn,
formUrlQuery,
formatAmount,
getAccountTypeColors,
} from "@/lib/utils";

const BankInfo = ({ account, appwriteItemId, type }: BankInfoProps) => {
const router = useRouter();
const searchParams = useSearchParams();

const isActive = appwriteItemId === account?.appwriteItemId;

const handleBankChange = () => {
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: account?.appwriteItemId,
});
router.push(newUrl, { scroll: false });
};

const colors = getAccountTypeColors(account?.type as AccountTypes);

return (








{account.name}


{type === "full" && (


{account.subtype}


)}


{formatAmount(account.currentBalance)}




);
};

export default BankInfo;
```

Copy.tsx

```typescript
"use client";
import { useState } from "react";

import { Button } from "./ui/button";

const Copy = ({ title }: { title: string }) => {
const [hasCopied, setHasCopied] = useState(false);

const copyToClipboard = () => {
navigator.clipboard.writeText(title);
setHasCopied(true);

setTimeout(() => {
setHasCopied(false);
}, 2000);
};

return (


{title}

{!hasCopied ? (




) : (



)}

);
};

export default Copy;
```

PaymentTransferForm.tsx

```typescript
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";

import { createTransfer } from "@/lib/actions/dwolla.actions";
import { createTransaction } from "@/lib/actions/transaction.actions";
import { getBank, getBankByAccountId } from "@/lib/actions/user.actions";
import { decryptId } from "@/lib/utils";

import { BankDropdown } from "./bank/BankDropdown";
import { Button } from "./ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "./ui/form";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";

const formSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(4, "Transfer note is too short"),
amount: z.string().min(4, "Amount is too short"),
senderBank: z.string().min(4, "Please select a valid bank account"),
sharableId: z.string().min(8, "Please select a valid sharable Id"),
});

const PaymentTransferForm = ({ accounts }: PaymentTransferFormProps) => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);

const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
amount: "",
senderBank: "",
sharableId: "",
},
});

const submit = async (data: z.infer) => {
setIsLoading(true);

try {
const receiverAccountId = decryptId(data.sharableId);
const receiverBank = await getBankByAccountId({
accountId: receiverAccountId,
});
const senderBank = await getBank({ documentId: data.senderBank });

const transferParams = {
sourceFundingSourceUrl: senderBank.fundingSourceUrl,
destinationFundingSourceUrl: receiverBank.fundingSourceUrl,
amount: data.amount,
};
// create transfer
const transfer = await createTransfer(transferParams);

// create transfer transaction
if (transfer) {
const transaction = {
name: data.name,
amount: data.amount,
senderId: senderBank.userId.$id,
senderBankId: senderBank.$id,
receiverId: receiverBank.userId.$id,
receiverBankId: receiverBank.$id,
email: data.email,
};

const newTransaction = await createTransaction(transaction);

if (newTransaction) {
form.reset();
router.push("/");
}
}
} catch (error) {
console.error("Submitting create transfer request failed: ", error);
}

setIsLoading(false);
};

return (


(




Select Source Bank


Select the bank account you want to transfer funds from










)}
/>

(




Transfer Note (Optional)


Please provide any additional information or instructions
related to the transfer










)}
/>



Bank account details



Enter the bank account details of the recipient


(



Recipient's Email Address









)}
/>

(



Receiver's Plaid Sharable Id









)}
/>

(



Amount









)}
/>



{isLoading ? (
<>
Β  Sending...
>
) : (
"Transfer Funds"
)}




);
};

export default PaymentTransferForm;
```

Missing from the video (top right on the transaction list page) BankDropdown.tsx

```typescript
"use client";

import Image from "next/image";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";

import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import { formUrlQuery, formatAmount } from "@/lib/utils";

export const BankDropdown = ({
accounts = [],
setValue,
otherStyles,
}: BankDropdownProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const [selected, setSeclected] = useState(accounts[0]);

const handleBankChange = (id: string) => {
const account = accounts.find((account) => account.appwriteItemId === id)!;

setSeclected(account);
const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "id",
value: id,
});
router.push(newUrl, { scroll: false });

if (setValue) {
setValue("senderBank", id);
}
};

return (
handleBankChange(value)}
>


{selected.name}






Select a bank to display

{accounts.map((account: Account) => (


{account.name}



{formatAmount(account.currentBalance)}




))}



);
};
```

Pagination.tsx

```typescript
"use client";

import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";

import { Button } from "@/components/ui/button";
import { formUrlQuery } from "@/lib/utils";

export const Pagination = ({ page, totalPages }: PaginationProps) => {
const router = useRouter();
const searchParams = useSearchParams()!;

const handleNavigation = (type: "prev" | "next") => {
const pageNumber = type === "prev" ? page - 1 : page + 1;

const newUrl = formUrlQuery({
params: searchParams.toString(),
key: "page",
value: pageNumber.toString(),
});

router.push(newUrl, { scroll: false });
};

return (


handleNavigation("prev")}
disabled={Number(page) <= 1}
>

Prev


{page} / {totalPages}


handleNavigation("next")}
disabled={Number(page) >= totalPages}
>
Next



);
};
```

Category.tsx

```typescript
import Image from "next/image";

import { topCategoryStyles } from "@/constants";
import { cn } from "@/lib/utils";

import { Progress } from "./ui/progress";

export const Category = ({ category }: CategoryProps) => {
const {
bg,
circleBg,
text: { main, count },
progress: { bg: progressBg, indicator },
icon,
} = topCategoryStyles[category.name as keyof typeof topCategoryStyles] ||
topCategoryStyles.default;

return (







{category.name}


{category.count}






);
};
```

## πŸ”— Links

Assets used in the project can be found [here](https://drive.google.com/file/d/1TVhdnD97LajGsyaiNa6sDs-ap-z1oerA/view?usp=sharing)