Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
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.
- Host: GitHub
- URL: https://github.com/nivindulakshitha/horizon
- Owner: nivindulakshitha
- License: mit
- Created: 2024-08-02T15:24:43.000Z (5 months ago)
- Default Branch: Master
- Last Pushed: 2024-09-07T11:01:01.000Z (4 months ago)
- Last Synced: 2024-10-31T20:06:59.318Z (about 2 months ago)
- Topics: appwrite, nextjs, saas, typescript
- Language: TypeScript
- Homepage: https://web-horizon.netlify.app/
- Size: 1.08 MB
- Stars: 1
- Watchers: 1
- Forks: 1
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
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)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.
- Next.js
- TypeScript
- Appwrite
- Plaid
- Dwolla
- React Hook Form
- Zod
- TailwindCSS
- Chart.js
- ShadCNπ **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.
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.
.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)