- Introduction
- Tech Stack
- Features
- Getting Started
- Project Structure
- Environment Variables
- Running the Project
- API Integration
- Contributing
- License
Welcome to the Financial Management App! This application is built with Next.js and TypeScript, providing users with a seamless experience to manage their finances, connect multiple bank accounts, and track transactions in real-time.
- Next.js: A React framework for server-side rendering.
- TypeScript: A superset of JavaScript that adds static types.
- Appwrite: A backend server for managing user authentication and data.
- Plaid: A service for connecting bank accounts.
- Dwolla: A payment platform for transferring funds.
- TailwindCSS: A utility-first CSS framework for styling.
- Zod: A TypeScript-first schema declaration and validation library.
- User Authentication: Secure sign-up and login functionality.
- Bank Account Integration: Connect multiple bank accounts using Plaid.
- Transaction Tracking: View and filter transactions in real-time.
- Funds Transfer: Transfer money between users using Dwolla.
- Responsive Design: Optimized for desktop, tablet, and mobile devices.
Follow these steps to set up the project locally on your machine.
Make sure you have the following installed:
git clone https://github.com/adrianhajdin/banking.git
cd bankingInstall the project dependencies using npm:
npm installThe project is organized as follows:
/banking
βββ /components # Reusable UI components
βββ /lib # Utility functions and API clients
βββ /pages # Next.js pages
βββ /public # Static assets
βββ /styles # Global styles
βββ /types # TypeScript type definitions
βββ /utils # Helper functions
Create a new file named .env in the root of your project and add the following content:
# 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=sandboxReplace the placeholder values with your actual account credentials.
To run the project locally, use the following command:
npm run devOpen http://localhost:3000 in your browser to view the application.
To connect bank accounts, the app uses Plaid's API. The following functions are essential:
- createLinkToken: Generates a link token for the Plaid Link.
- exchangePublicToken: Exchanges the public token for an access token.
For transferring funds, the app integrates with Dwolla. Key functions include:
- createFundingSource: Creates a funding source using a Plaid processor token.
- createTransfer: Initiates a transfer between accounts.
To set up the Appwrite database for the Financial Management App, follow these steps:
- Log in to your Appwrite console.
- Navigate to the Databases section.
- Click on Add Database.
- Enter a name for your database (e.g.,
FinancialManagement). - Click Create.
You will need to create the following collections within your database:
- Collection Name:
users - Attributes:
email(String, required) - User's email address.userId(String, required) - Unique identifier for the user.dwollaCustomerUrl(String, required) - URL for the user's Dwolla customer profile.dwollaCustomerId(String, required) - Unique identifier for the Dwolla customer.firstName(String, required) - User's first name.lastName(String, required) - User's last name.address1(String, required) - User's primary address.city(String, required) - User's city.postalCode(String, required) - User's postal code.dateOfBirth(String, required) - User's date of birth.ssn(String, optional) - User's Social Security Number (if applicable).state(String, required) - User's state of residence.
- Collection Name:
banks - Attributes:
accountId(String, required) - Unique identifier for the bank account.bankId(String, required) - Reference to the bank institution.accessToken(String, required) - Token used to access the bank account.fundingSourceUrl(String, required) - URL for the funding source.shareableId(String, required) - Unique identifier for sharing the bank account.userId(String, required) - Reference to theuserscollection.
- Collection Name:
transactions - Attributes:
bankId(String, required) - Reference to thebankscollection.name(String, required).amount(Float, required).date(Date, required).channel(String, optional).category(String, optional).senderBankId(String, required).
To establish relationships between collections:
- In the Users collection, ensure that the
userIdattribute is unique. - In the Banks collection, the
userIdattribute should reference theuserscollection. - In the Transactions collection, the
bankIdattribute should reference thebankscollection.
Set up the necessary permissions for each collection based on your application's requirements. For example:
- Users Collection: Allow read and write access to authenticated users.
- Banks Collection: Allow read and write access to the user who owns the bank account.
- Transactions Collection: Allow read access to the user who owns the bank account and write access for creating transactions.
After creating the database and collections, you can test the setup by using the Appwrite SDK to create, read, update, and delete documents in your collections.
Hereβs an example of how to create a user in the users collection using the Appwrite SDK:
import { Client, Account } from "appwrite";
const client = new Client();
client.setEndpoint('https://YOUR_APPWRITE_ENDPOINT/v1').setProject('YOUR_PROJECT_ID');
const account = new Account(client);
const createUser = async (email: string, password: string, firstName: string, lastName: string) => {
try {
const user = await account.create(ID.unique(), email, password, `${firstName} ${lastName}`);
console.log('User created:', user);
} catch (error) {
console.error('Error creating user:', error);
}
};By following these steps, you will have a fully functional Appwrite database set up for your Financial Management App. Make sure to adjust the permissions and relationships according to your application's needs.
### Summary of Changes:
- Added a new section titled "Setting Up the Appwrite Database."
- Provided detailed instructions on creating the database, collections, attributes, and relationships.
- Included example code for creating a user in the `users` collection.
Feel free to adjust the content as necessary to fit your project's specific requirements!
## π Additional Resources and Snippets
</details>
<details>
<summary><code>exchangePublicToken</code></summary>
```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
"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
"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
"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
"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 (
<div
onClick={handleBankChange}
className={cn(`banktab-item`, {
" border-blue-600": isActive,
})}
>
<p
className={cn(`text-16 line-clamp-1 flex-1 font-medium text-gray-500`, {
" text-blue-600": isActive,
})}
>
{account.name}
</p>
</div>
);
};BankInfo.tsx
"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 (
<div
onClick={handleBankChange}
className={cn(`bank-info ${colors.bg}`, {
"shadow-sm border-blue-700": type === "card" && isActive,
"rounded-xl": type === "card",
"hover:shadow-sm cursor-pointer": type === "card",
})}
>
<figure
className={`flex-center h-fit rounded-full bg-blue-100 ${colors.lightBg}`}
>
<Image
src="/icons/connect-bank.svg"
width={20}
height={20}
alt={account.subtype}
className="m-2 min-w-5"
/>
</figure>
<div className="flex w-full flex-1 flex-col justify-center gap-1">
<div className="bank-info_content">
<h2
className={`text-16 line-clamp-1 flex-1 font-bold text-blue-900 ${colors.title}`}
>
{account.name}
</h2>
{type === "full" && (
<p
className={`text-12 rounded-full px-3 py-1 font-medium text-blue-700 ${colors.subText} ${colors.lightBg}`}
>
{account.subtype}
</p>
)}
</div>
<p className={`text-16 font-medium text-blue-700 ${colors.subText}`}>
{formatAmount(account.currentBalance)}
</p>
</div>
</div>
);
};
export default BankInfo;Copy.tsx
"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 (
<Button
data-state="closed"
className="mt-3 flex max-w-[320px] gap-4"
variant="secondary"
onClick={copyToClipboard}
>
<p className="line-clamp-1 w-full max-w-full text-xs font-medium text-black-2">
{title}
</p>
{!hasCopied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="mr-2 size-4"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="mr-2 size-4"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</Button>
);
};
export default Copy;PaymentTransferForm.tsx
"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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
amount: "",
senderBank: "",
sharableId: "",
},
});
const submit = async (data: z.infer<typeof formSchema>) => {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(submit)} className="flex flex-col">
<FormField
control={form.control}
name="senderBank"
render={() => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-6 pt-5">
<div className="payment-transfer_form-content">
<FormLabel className="text-14 font-medium text-gray-700">
Select Source Bank
</FormLabel>
<FormDescription className="text-12 font-normal text-gray-600">
Select the bank account you want to transfer funds from
</FormDescription>
</div>
<div className="flex w-full flex-col">
<FormControl>
<BankDropdown
accounts={accounts}
setValue={form.setValue}
otherStyles="!w-full"
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-6 pt-5">
<div className="payment-transfer_form-content">
<FormLabel className="text-14 font-medium text-gray-700">
Transfer Note (Optional)
</FormLabel>
<FormDescription className="text-12 font-normal text-gray-600">
Please provide any additional information or instructions
related to the transfer
</FormDescription>
</div>
<div className="flex w-full flex-col">
<FormControl>
<Textarea
placeholder="Write a short note here"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<div className="payment-transfer_form-details">
<h2 className="text-18 font-semibold text-gray-900">
Bank account details
</h2>
<p className="text-16 font-normal text-gray-600">
Enter the bank account details of the recipient
</p>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item py-5">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Recipient's Email Address
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="ex: johndoe@gmail.com"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="sharableId"
render={({ field }) => (
<FormItem className="border-t border-gray-200">
<div className="payment-transfer_form-item pb-5 pt-6">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Receiver's Plaid Sharable Id
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="Enter the public account number"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem className="border-y border-gray-200">
<div className="payment-transfer_form-item py-5">
<FormLabel className="text-14 w-full max-w-[280px] font-medium text-gray-700">
Amount
</FormLabel>
<div className="flex w-full flex-col">
<FormControl>
<Input
placeholder="ex: 5.00"
className="input-class"
{...field}
/>
</FormControl>
<FormMessage className="text-12 text-red-500" />
</div>
</div>
</FormItem>
)}
/>
<div className="payment-transfer_btn-box">
<Button type="submit" className="payment-transfer_btn">
{isLoading ? (
<>
<Loader2 size={20} className="animate-spin" /> Sending...
</>
) : (
"Transfer Funds"
)}
</Button>
</div>
</form>
</Form>
);
};
export default PaymentTransferForm; BankDropdown.tsx
"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 (
<Select
defaultValue={selected.id}
onValueChange={(value) => handleBankChange(value)}
>
<SelectTrigger
className={`flex w-full gap-3 md:w-[300px] ${otherStyles}`}
>
<Image
src="icons/credit-card.svg"
width={20}
height={20}
alt="account"
/>
<p className="line-clamp-1 w-full text-left">{selected.name}</p>
</SelectTrigger>
<SelectContent
className={`w-full md:w-[300px] ${otherStyles}`}
align="end"
>
<SelectGroup>
<SelectLabel className="py-2 font-normal text-gray-500">
Select a bank to display
</SelectLabel>
{accounts.map((account: Account) => (
<SelectItem
key={account.id}
value={account.appwriteItemId}
className="cursor-pointer border-t"
>
<div className="flex flex-col ">
<p className="text-16 font-medium">{account.name}</p>
<p className="text-14 font-medium text-blue-600">
{formatAmount(account.currentBalance)}
</p>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
};Pagination.tsx
"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 (
<div className="flex justify-between gap-3">
<Button
size="lg"
variant="ghost"
className="p-0 hover:bg-transparent"
onClick={() => handleNavigation("prev")}
disabled={Number(page) <= 1}
>
<Image
src="/icons/arrow-left.svg"
alt="arrow"
width={20}
height={20}
className="mr-2"
/>
Prev
</Button>
<p className="text-14 flex items-center px-2">
{page} / {totalPages}
</p>
<Button
size="lg"
variant="ghost"
className="p-0 hover:bg-transparent"
onClick={() => handleNavigation("next")}
disabled={Number(page) >= totalPages}
>
Next
<Image
src="/icons/arrow-left.svg"
alt="arrow"
width={20}
height={20}
className="ml-2 -scale-x-100"
/>
</Button>
</div>
);
};Category.tsx
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 (
<div className={cn("gap-[18px] flex p-4 rounded-xl", bg)}>
<figure className={cn("flex-center size-10 rounded-full", circleBg)}>
<Image src={icon} width={20} height={20} alt={category.name} />
</figure>
<div className="flex w-full flex-1 flex-col gap-2">
<div className="text-14 flex justify-between">
<h2 className={cn("font-medium", main)}>{category.name}</h2>
<h3 className={cn("font-normal", count)}>{category.count}</h3>
</div>
<Progress
value={(category.count / category.totalCount) * 100}
className={cn("h-2 w-full", progressBg)}
indicatorClassName={cn("h-2 w-full", indicator)}
/>
</div>
</div>
);
};Assets used in the project can be found here
We welcome contributions! If you would like to contribute to this project, please follow these steps:
- Fork the repository.
- Create a new branch (
git checkout -b feature/YourFeature). - Make your changes and commit them (
git commit -m 'Add some feature'). - Push to the branch (
git push origin feature/YourFeature). - Open a pull request.
This project is licensed under the MIT License. See the LICENSE file for details.
Thank you for checking out the Financial Management App documentation! We hope this guide helps you in developing and contributing to the project. If you have any questions, feel free to reach out to the community or the maintainers.
