- {/*
*/}
- {/*
*/}
{t('actors.you')} {t('ui.expense.you.owe')}
@@ -71,9 +69,7 @@ const BalancePage: NextPageWithUser = () => {
{balance.currency.toUpperCase()} {toUIString(balance.amount)}
- {index !== balanceQuery.data.youOwe.length - 1 ? (
-
+
- ) : null}
+ {index !== balanceQuery.data.youOwe.length - 1 ?
+ : null}
))}
@@ -81,7 +77,7 @@ const BalancePage: NextPageWithUser = () => {
) : null}
{balanceQuery.data?.youGet.length ? (
-
+
{t('actors.you')} {t('ui.expense.you.lent')}
@@ -91,9 +87,9 @@ const BalancePage: NextPageWithUser = () => {
{balanceQuery.data?.youGet.map((balance, index) => (
-
+
{balance.currency.toUpperCase()} {toUIString(balance.amount)}
-
{' '}
+
{index !== balanceQuery.data.youGet.length - 1 ? (
+
) : null}
@@ -103,38 +99,38 @@ const BalancePage: NextPageWithUser = () => {
) : null}
+
-
- {balanceQuery.data?.balances.map((balance) => (
-
- ))}
+
+ {balanceQuery.data?.balances.map((balance) => (
+
+ ))}
- {!balanceQuery.isPending && !balanceQuery.data?.balances.length ? (
-
-
-
-
- {!isPwa &&
{t('ui.or')}
}
-
-
-
-
- ) : null}
-
+ {!balanceQuery.isPending && !balanceQuery.data?.balances.length ? (
+
+
+
+
+ {!isPwa &&
{t('ui.or')}
}
+
+
+
+
+ ) : null}
>
diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx
index adeae7b0..e735226e 100644
--- a/src/pages/balances/[friendId].tsx
+++ b/src/pages/balances/[friendId].tsx
@@ -22,7 +22,7 @@ const FriendPage: NextPageWithUser = ({ user }) => {
const router = useRouter();
const { friendId } = router.query;
- const _friendId = parseInt(friendId as string);
+ const _friendId = parseInt(Array.isArray(friendId) ? (friendId[0] ?? '') : (friendId ?? ''));
const friendQuery = api.user.getFriend.useQuery(
{ friendId: _friendId },
diff --git a/src/pages/home.tsx b/src/pages/home.tsx
index f3d39475..caf51859 100644
--- a/src/pages/home.tsx
+++ b/src/pages/home.tsx
@@ -7,6 +7,7 @@ import {
GitFork,
Globe,
Import,
+ Landmark,
Merge,
Split,
Users,
@@ -218,6 +219,15 @@ export default function Home() {
{t('features.currency_conversion.description')}
+
+
+
+
{t('features.bank_connection.title')}
+
+
+ {t('features.bank_connection.description')}
+
+
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 4bc3d896..951725e6 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -2,6 +2,7 @@ import { groupRouter } from '~/server/api/routers/group';
import { createTRPCRouter } from '~/server/api/trpc';
import { userRouter } from './routers/user';
+import { bankTransactionsRouter } from './routers/bankTransactions';
import { expenseRouter } from './routers/expense';
/**
@@ -12,6 +13,7 @@ import { expenseRouter } from './routers/expense';
export const appRouter = createTRPCRouter({
group: groupRouter,
user: userRouter,
+ bankTransactions: bankTransactionsRouter,
expense: expenseRouter,
});
diff --git a/src/server/api/routers/bankTransactions.ts b/src/server/api/routers/bankTransactions.ts
new file mode 100644
index 00000000..247f5585
--- /dev/null
+++ b/src/server/api/routers/bankTransactions.ts
@@ -0,0 +1,92 @@
+import { createTRPCRouter, protectedProcedure } from '../trpc';
+import { z } from 'zod';
+import { whichBankConnectionConfigured } from '~/server/bankTransactionHelper';
+import { InstitutionsOutput, TransactionOutput } from '~/types/bank.types';
+import { bankTransactionService } from '../services/bankTransactions/bankTransactionService';
+import { TRPCError } from '@trpc/server';
+
+export const bankTransactionsRouter = createTRPCRouter({
+ getTransactions: protectedProcedure
+ .input(z.string().optional())
+ .output(TransactionOutput.optional())
+ .query(
+ async ({ input: token, ctx }) =>
+ await bankTransactionService.getTransactions(ctx.session.user.id, token),
+ ),
+ connectToBank: protectedProcedure
+ .input(z.string().optional())
+ .output(
+ z
+ .object({
+ institutionId: z.string(),
+ authLink: z.string(),
+ })
+ .optional(),
+ )
+ .mutation(async ({ input: institutionId, ctx }) => {
+ const provider = whichBankConnectionConfigured();
+ if (provider === 'GOCARDLESS') {
+ // Deprecated
+ const res = await bankTransactionService
+ .getProvider()
+ .connectToBank(institutionId, ctx.session.user.preferredLanguage);
+
+ if (!res) {
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to link to bank' });
+ }
+
+ await ctx.db.user.update({
+ where: {
+ id: ctx.session.user.id,
+ },
+ data: {
+ obapiProviderId: res.institutionId,
+ },
+ });
+
+ return res;
+ }
+ const res = await bankTransactionService.connectToBank(
+ ctx.session.user.id.toString(),
+ ctx.session.user.preferredLanguage,
+ );
+ return res;
+ }),
+ getInstitutions: protectedProcedure
+ .output(InstitutionsOutput)
+ .query(async () => await bankTransactionService.getInstitutions()),
+ // Explicit for PLAID
+ exchangePublicToken: protectedProcedure
+ .input(z.string())
+ .output(
+ z
+ .object({
+ accessToken: z.string(),
+ itemId: z.string(),
+ })
+ .optional(),
+ )
+ .mutation(async ({ input: publicToken, ctx }) => {
+ if (whichBankConnectionConfigured() === 'PLAID') {
+ const res = await bankTransactionService.getProvider().exchangePublicToken?.(publicToken);
+
+ if (!res) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Failed to exchange public token',
+ });
+ }
+
+ await ctx.db.user.update({
+ where: {
+ id: ctx.session.user.id,
+ },
+ data: {
+ obapiProviderId: res.accessToken,
+ },
+ });
+
+ return res;
+ }
+ }),
+});
diff --git a/src/server/api/routers/nordigen-node.d.ts b/src/server/api/routers/nordigen-node.d.ts
new file mode 100644
index 00000000..6793e453
--- /dev/null
+++ b/src/server/api/routers/nordigen-node.d.ts
@@ -0,0 +1,86 @@
+declare module 'nordigen-node' {
+ interface Transaction {
+ transactionId: string;
+ entryReference: string;
+ bookingDate: string;
+ valueDate: string;
+ transactionAmount: {
+ amount: string;
+ currency: string;
+ };
+ remittanceInformationUnstructured: string;
+ remittanceInformationStructured: string;
+ additionalInformation: string;
+ internalTransactionId: string;
+ }
+
+ interface GetTransactions {
+ transactions: {
+ booked: Transaction[];
+ pending: Transaction[];
+ };
+ }
+
+ interface Requisition {
+ id: string;
+ created: string;
+ redirect: string;
+ status: string;
+ institution_id: string;
+ agreement?: string;
+ reference: string;
+ accounts: string[];
+ user_language: string;
+ link: string;
+ ssn: string | null;
+ account_selection: boolean;
+ redirect_immediate: boolean;
+ }
+
+ interface Account {
+ getTransactions({
+ dateFrom,
+ dateTo,
+ }: {
+ dateFrom: string;
+ dateTo: string;
+ }): Promise
;
+ }
+
+ interface Init {
+ id: string;
+ link: string;
+ }
+
+ interface Institution {
+ id: string;
+ name: string;
+ bic: string;
+ transaction_total_days: string;
+ countries: string[];
+ logo: string;
+ }
+
+ export default class NordigenClient {
+ constructor(options: { secretId?: string; secretKey?: string });
+
+ generateToken(): Promise;
+ account(accountId: string): Account;
+ initSession(options: {
+ redirectUrl: string;
+ institutionId: string;
+ referenceId: string;
+ user_language: string;
+ redirect_immediate: boolean;
+ account_selection: boolean;
+ }): Promise;
+
+ requisition: {
+ getRequisitionById(requisitionId: string): Promise;
+ };
+
+ institution: {
+ getInstitutions(options?: { country?: string }): Promise;
+ };
+ }
+}
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts
index 25c8172f..ac497ed9 100644
--- a/src/server/api/routers/user.ts
+++ b/src/server/api/routers/user.ts
@@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
+import { type User } from 'next-auth';
import { z } from 'zod';
import { env } from '~/env';
@@ -42,6 +43,23 @@ export const userRouter = createTRPCRouter({
return friends;
}),
+ getOwnExpenses: protectedProcedure.query(async ({ ctx }) => {
+ const expenses = await db.expense.findMany({
+ where: {
+ paidBy: ctx.session.user.id,
+ deletedBy: null,
+ },
+ orderBy: {
+ expenseDate: 'desc',
+ },
+ include: {
+ group: true,
+ },
+ });
+
+ return expenses;
+ }),
+
inviteFriend: protectedProcedure
.input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() }))
.mutation(async ({ input, ctx: { session } }) => {
@@ -92,6 +110,8 @@ export const userRouter = createTRPCRouter({
z.object({
name: z.string().optional(),
currency: z.string().optional(),
+ obapiProviderId: z.string().optional(),
+ bankingId: z.string().optional(),
preferredLanguage: z.string().optional(),
}),
)
@@ -135,7 +155,7 @@ export const userRouter = createTRPCRouter({
submitFeedback: protectedProcedure
.input(z.object({ feedback: z.string().min(10) }))
.mutation(async ({ input, ctx }) => {
- await sendFeedbackEmail(input.feedback, ctx.session.user);
+ await sendFeedbackEmail(input.feedback, ctx.session.user as User);
}),
getFriend: protectedProcedure
diff --git a/src/server/api/services/bankTransactions/bankTransactionService.ts b/src/server/api/services/bankTransactions/bankTransactionService.ts
new file mode 100644
index 00000000..d0376fea
--- /dev/null
+++ b/src/server/api/services/bankTransactions/bankTransactionService.ts
@@ -0,0 +1,68 @@
+import { type BankProviders, whichBankConnectionConfigured } from '~/server/bankTransactionHelper';
+import type { TransactionOutput } from '~/types/bank.types';
+import { GoCardlessService } from './gocardless';
+import { PlaidService } from './plaid';
+import { TRPCError } from '@trpc/server';
+
+abstract class AbstractBankTransactionService {
+ abstract getTransactions(userId: number, token?: string): Promise;
+ abstract connectToBank(
+ id?: string,
+ preferredLanguage?: string,
+ ): Promise<{ institutionId: string; authLink: string } | undefined>;
+ abstract getInstitutions(): Promise<{ id: string; name: string; logo: string }[]>;
+ exchangePublicToken?(
+ publicToken: string,
+ ): Promise<{ accessToken: string; itemId: string } | undefined>;
+}
+
+export class BankTransactionService {
+ private readonly connectedProvider: BankProviders | null;
+ private readonly provider: AbstractBankTransactionService | null;
+
+ constructor() {
+ this.connectedProvider = whichBankConnectionConfigured();
+ this.provider =
+ this.connectedProvider === 'GOCARDLESS'
+ ? new GoCardlessService()
+ : this.connectedProvider === 'PLAID'
+ ? new PlaidService()
+ : null;
+
+ if (!this.provider) {
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Provider not found' });
+ }
+ }
+
+ getProvider(): AbstractBankTransactionService {
+ if (!this.provider) {
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Provider not found' });
+ }
+ return this.provider;
+ }
+
+ async getTransactions(userId: number, token?: string): Promise {
+ return this.provider?.getTransactions(userId, token);
+ }
+
+ async connectToBank(
+ id?: string,
+ preferredLanguage?: string,
+ ): Promise<{ institutionId: string; authLink: string } | undefined> {
+ const res = this.provider?.connectToBank(id, preferredLanguage);
+ if (!res) {
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to link to bank' });
+ }
+
+ return res;
+ }
+
+ async getInstitutions(): Promise<{ id: string; name: string; logo: string }[]> {
+ if (!this.provider) {
+ return [];
+ }
+ return this.provider?.getInstitutions();
+ }
+}
+
+export const bankTransactionService = new BankTransactionService();
diff --git a/src/server/api/services/bankTransactions/gocardless.ts b/src/server/api/services/bankTransactions/gocardless.ts
new file mode 100644
index 00000000..99b607a9
--- /dev/null
+++ b/src/server/api/services/bankTransactions/gocardless.ts
@@ -0,0 +1,188 @@
+// @deprecated
+
+import { format, subDays } from 'date-fns';
+import NordigenClient, { type Transaction } from 'nordigen-node';
+import { env } from '~/env';
+import { getDbCachedData, setDbCachedData } from '../dbCache';
+import type { CachedBankData } from '@prisma/client';
+import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types';
+import { TRPCError } from '@trpc/server';
+
+abstract class AbstractBankProvider {
+ abstract getTransactions(userId: number, token?: string): Promise;
+ abstract connectToBank(
+ id?: string,
+ preferredLanguage?: string,
+ ): Promise<{ institutionId: string; authLink: string } | undefined>;
+ abstract getInstitutions(): Promise<{ id: string; name: string; logo: string }[]>;
+}
+
+const GOCARDLESS_CONSTANTS = {
+ DEFAULT_INTERVAL_DAYS: 30,
+ RANDOM_ID_LENGTH: 60,
+ DEFAULT_LANGUAGE: 'EN',
+ DATE_FORMAT: 'yyyy-MM-dd',
+} as const;
+
+const ERROR_MESSAGES = {
+ FAILED_FETCH_CACHED: 'Failed to fetch cached transactions',
+ FAILED_FETCH_INSTITUTIONS: 'Failed to fetch institutions',
+ FAILED_FETCH_TRANSACTIONS: 'Failed to fetch transactions',
+ FAILED_LINK_BANK: 'Failed to link to bank',
+} as const;
+
+export class GoCardlessService extends AbstractBankProvider {
+ private readonly client: NordigenClient;
+
+ constructor() {
+ super();
+ this.client = new NordigenClient({
+ secretId: env.GOCARDLESS_SECRET_ID,
+ secretKey: env.GOCARDLESS_SECRET_KEY,
+ });
+ }
+
+ generateRandomId(length: number = GOCARDLESS_CONSTANTS.RANDOM_ID_LENGTH) {
+ return Array.from({ length }, () => Math.random().toString(36)[2]).join('');
+ }
+
+ returnTransactionFilters() {
+ const intervalInDays =
+ env.GOCARDLESS_INTERVAL_IN_DAYS ?? GOCARDLESS_CONSTANTS.DEFAULT_INTERVAL_DAYS;
+
+ return {
+ dateTo: format(new Date(), GOCARDLESS_CONSTANTS.DATE_FORMAT),
+ dateFrom: format(subDays(new Date(), intervalInDays), GOCARDLESS_CONSTANTS.DATE_FORMAT),
+ };
+ }
+
+ async getTransactions(userId: number, requisitionId?: string) {
+ if (!requisitionId) {
+ return;
+ }
+
+ await this.client.generateToken();
+
+ const requisitionData = await this.client.requisition.getRequisitionById(requisitionId);
+
+ const accountId = requisitionData.accounts[0];
+
+ const cachedData = await getDbCachedData({
+ key: 'cachedBankData',
+ where: { obapiProviderId: accountId, userId },
+ });
+
+ if (cachedData) {
+ if (!cachedData.data) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_CACHED,
+ });
+ }
+ return JSON.parse(cachedData.data) as TransactionOutput;
+ }
+
+ const account = this.client.account(accountId ?? '');
+
+ const transactions = await account.getTransactions(this.returnTransactionFilters());
+
+ if (!transactions) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_TRANSACTIONS,
+ });
+ }
+
+ const formattedTransactions: TransactionOutput = this.formatTransactions(transactions);
+
+ await setDbCachedData({
+ key: 'cachedBankData',
+ where: { obapiProviderId: accountId ?? '', userId },
+ data: {
+ obapiProviderId: accountId ?? '',
+ data: JSON.stringify(formattedTransactions),
+ lastFetched: new Date(),
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ },
+ });
+
+ return formattedTransactions;
+ }
+
+ async connectToBank(institutionId?: string, preferredLanguage?: string) {
+ if (!institutionId) {
+ return;
+ }
+
+ await this.client.generateToken();
+
+ const init = await this.client.initSession({
+ redirectUrl: env.NEXTAUTH_URL,
+ institutionId: institutionId,
+ referenceId: this.generateRandomId(),
+ user_language: preferredLanguage?.toUpperCase() ?? GOCARDLESS_CONSTANTS.DEFAULT_LANGUAGE,
+ redirect_immediate: false,
+ account_selection: false,
+ });
+
+ if (!init) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_LINK_BANK,
+ });
+ }
+
+ return {
+ institutionId: init.id,
+ authLink: init.link,
+ };
+ }
+
+ async getInstitutions() {
+ await this.client.generateToken();
+
+ const institutionsData = await this.client.institution.getInstitutions(
+ env.GOCARDLESS_COUNTRY ? { country: env.GOCARDLESS_COUNTRY } : undefined,
+ );
+
+ if (!institutionsData) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_INSTITUTIONS,
+ });
+ }
+
+ return institutionsData.map((institution) => ({
+ id: institution.id,
+ name: institution.name,
+ logo: institution.logo,
+ }));
+ }
+
+ private formatTransaction(transaction: Transaction): TransactionOutputItem {
+ return {
+ transactionId: transaction.transactionId,
+ bookingDate: transaction.bookingDate,
+ description: transaction.remittanceInformationUnstructured,
+ transactionAmount: {
+ amount: transaction.transactionAmount.amount,
+ currency: transaction.transactionAmount.currency,
+ },
+ };
+ }
+
+ private formatTransactions(transactions: {
+ transactions: { booked: Transaction[]; pending: Transaction[] };
+ }): TransactionOutput {
+ return {
+ transactions: {
+ booked: transactions.transactions.booked.map((t) => this.formatTransaction(t)),
+ pending: transactions.transactions.pending.map((t) => this.formatTransaction(t)),
+ },
+ };
+ }
+}
diff --git a/src/server/api/services/bankTransactions/plaid.ts b/src/server/api/services/bankTransactions/plaid.ts
new file mode 100644
index 00000000..fb0d88c5
--- /dev/null
+++ b/src/server/api/services/bankTransactions/plaid.ts
@@ -0,0 +1,220 @@
+import {
+ Configuration,
+ CountryCode,
+ PlaidApi,
+ PlaidEnvironments,
+ Products,
+ type Transaction,
+} from 'plaid';
+import { env } from '~/env';
+import { getDbCachedData, setDbCachedData } from '../dbCache';
+import type { CachedBankData } from '@prisma/client';
+import type { TransactionOutput, TransactionOutputItem } from '~/types/bank.types';
+import { TRPCError } from '@trpc/server';
+import { format, subDays } from 'date-fns';
+
+abstract class AbstractBankProvider {
+ abstract getTransactions(userId: number, token?: string): Promise;
+ abstract connectToBank(
+ id?: string,
+ preferredLanguage?: string,
+ ): Promise<{ institutionId: string; authLink: string } | undefined>;
+ abstract getInstitutions(): Promise<{ id: string; name: string; logo: string }[]>;
+ exchangePublicToken(
+ _publicToken: string,
+ ): Promise<{ accessToken: string; itemId: string } | undefined> {
+ return Promise.resolve(undefined);
+ }
+}
+
+const PLAID_CONSTANTS = {
+ DEFAULT_INTERVAL_DAYS: 30,
+ RANDOM_ID_LENGTH: 60,
+ DEFAULT_LANGUAGE: 'en',
+ DATE_FORMAT: 'yyyy-MM-dd',
+} as const;
+
+const ERROR_MESSAGES = {
+ FAILED_FETCH_CACHED: 'Failed to fetch cached transactions',
+ FAILED_FETCH_INSTITUTIONS: 'Failed to fetch institutions',
+ FAILED_FETCH_TRANSACTIONS: 'Failed to fetch transactions',
+ FAILED_LINK_BANK: 'Failed to link to bank',
+ FAILED_CREATE_LINK_TOKEN: 'Failed to create link token',
+ FAILED_EXCHANGE_TOKEN: 'Failed to exchange public token',
+} as const;
+
+export class PlaidService extends AbstractBankProvider {
+ private readonly client: PlaidApi;
+
+ constructor() {
+ super();
+ this.client = new PlaidApi(
+ new Configuration({
+ basePath:
+ env.PLAID_ENVIRONMENT === 'production'
+ ? PlaidEnvironments.production
+ : PlaidEnvironments.sandbox,
+ baseOptions: {
+ headers: {
+ 'PLAID-CLIENT-ID': env.PLAID_CLIENT_ID,
+ 'PLAID-SECRET': env.PLAID_SECRET,
+ },
+ },
+ }),
+ );
+ }
+
+ returnTransactionFilters() {
+ const intervalInDays = env.PLAID_INTERVAL_IN_DAYS ?? PLAID_CONSTANTS.DEFAULT_INTERVAL_DAYS;
+
+ return {
+ start_date: format(subDays(new Date(), intervalInDays), PLAID_CONSTANTS.DATE_FORMAT),
+ end_date: format(new Date(), PLAID_CONSTANTS.DATE_FORMAT),
+ };
+ }
+
+ async getTransactions(userId: number, accessToken?: string) {
+ if (!accessToken) {
+ return;
+ }
+
+ const cachedData = await getDbCachedData({
+ key: 'cachedBankData',
+ where: { obapiProviderId: accessToken, userId },
+ });
+
+ if (cachedData) {
+ if (!cachedData.data) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_CACHED,
+ });
+ }
+ return JSON.parse(cachedData.data) as TransactionOutput;
+ }
+
+ const response = await this.client.transactionsGet({
+ access_token: accessToken,
+ ...this.returnTransactionFilters(),
+ });
+
+ if (!response.data.transactions) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_TRANSACTIONS,
+ });
+ }
+
+ const formattedTransactions: TransactionOutput = this.formatTransactions(
+ response.data.transactions,
+ );
+
+ await setDbCachedData({
+ key: 'cachedBankData',
+ where: { obapiProviderId: accessToken, userId },
+ data: {
+ obapiProviderId: accessToken,
+ data: JSON.stringify(formattedTransactions),
+ lastFetched: new Date(),
+ user: {
+ connect: {
+ id: userId,
+ },
+ },
+ },
+ });
+
+ return formattedTransactions;
+ }
+
+ async connectToBank(id: string, preferredLanguage?: string) {
+ const response = await this.client.linkTokenCreate({
+ user: {
+ client_user_id: id,
+ },
+ client_name: 'Split Pro',
+ products: [Products.Transactions],
+ country_codes: env.PLAID_COUNTRY_CODES
+ ? (env.PLAID_COUNTRY_CODES.split(',') as CountryCode[])
+ : [CountryCode.Us],
+ language: preferredLanguage?.toLowerCase() ?? PLAID_CONSTANTS.DEFAULT_LANGUAGE,
+ });
+
+ if (!response.data.link_token) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_CREATE_LINK_TOKEN,
+ });
+ }
+
+ return {
+ authLink: response.data.link_token,
+ institutionId: id ?? '',
+ };
+ }
+
+ async exchangePublicToken(publicToken: string) {
+ const response = await this.client.itemPublicTokenExchange({
+ public_token: publicToken,
+ });
+
+ if (!response.data.access_token) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_EXCHANGE_TOKEN,
+ });
+ }
+
+ return {
+ accessToken: response.data.access_token,
+ itemId: response.data.item_id,
+ };
+ }
+
+ async getInstitutions() {
+ const response = await this.client.institutionsGet({
+ country_codes: env.PLAID_COUNTRY_CODES
+ ? (env.PLAID_COUNTRY_CODES.split(',') as CountryCode[])
+ : [CountryCode.Us],
+ count: 500,
+ offset: 0,
+ });
+
+ if (!response.data.institutions) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: ERROR_MESSAGES.FAILED_FETCH_INSTITUTIONS,
+ });
+ }
+
+ return response.data.institutions.map((institution) => ({
+ id: institution.institution_id,
+ name: institution.name,
+ logo: institution.logo || '',
+ }));
+ }
+
+ private formatTransaction(transaction: Transaction): TransactionOutputItem {
+ return {
+ transactionId: transaction.transaction_id,
+ bookingDate: transaction.date,
+ description: transaction.name || transaction.merchant_name || '?',
+ transactionAmount: {
+ amount: transaction.amount?.toString() || '0',
+ currency: transaction.iso_currency_code || 'USD',
+ },
+ };
+ }
+
+ private formatTransactions(transactions: Transaction[]): TransactionOutput {
+ const bookedTransactions = transactions.filter((t) => t.pending === false);
+ const pendingTransactions = transactions.filter((t) => t.pending === true);
+
+ return {
+ transactions: {
+ booked: bookedTransactions.map((t) => this.formatTransaction(t)),
+ pending: pendingTransactions.map((t) => this.formatTransaction(t)),
+ },
+ };
+ }
+}
diff --git a/src/server/api/services/dbCache.ts b/src/server/api/services/dbCache.ts
new file mode 100644
index 00000000..66404b6b
--- /dev/null
+++ b/src/server/api/services/dbCache.ts
@@ -0,0 +1,56 @@
+import { db } from '~/server/db';
+import type { Prisma } from '@prisma/client';
+
+type CachedBankDataKey = 'cachedBankData';
+
+interface DbCachedData {
+ cachedBankData: {
+ where: Prisma.CachedBankDataWhereUniqueInput;
+ data: Prisma.CachedBankDataCreateInput;
+ };
+}
+
+interface GetDbCachedDataParams {
+ key: K;
+ where: DbCachedData[K]['where'];
+ maxAgeMs?: number;
+}
+
+interface SetDbCachedDataParams {
+ key: K;
+ where: DbCachedData[K]['where'];
+ data: DbCachedData[K]['data'];
+}
+
+async function getDbCachedData({
+ key,
+ where,
+ maxAgeMs = 24 * 60 * 60 * 1000,
+}: GetDbCachedDataParams): Promise {
+ const minLastFetched = new Date(Date.now() - maxAgeMs);
+
+ const cached = await db[key].findUnique({
+ where: {
+ ...where,
+ lastFetched: {
+ gt: minLastFetched,
+ },
+ } as DbCachedData[K]['where'],
+ });
+
+ return cached as T | null;
+}
+
+async function setDbCachedData({
+ key,
+ where,
+ data,
+}: SetDbCachedDataParams): Promise {
+ await db[key].upsert({
+ where,
+ update: data,
+ create: data,
+ });
+}
+
+export { getDbCachedData, setDbCachedData };
diff --git a/src/server/api/services/scheduleService.ts b/src/server/api/services/scheduleService.ts
new file mode 100644
index 00000000..e17c01b8
--- /dev/null
+++ b/src/server/api/services/scheduleService.ts
@@ -0,0 +1,22 @@
+import { db } from '~/server/db';
+
+export const createRecurringDeleteBankCacheJob = async (frequency: 'weekly' | 'monthly') => {
+ // Implementation for creating a recurring delete bank cache using pg_cron
+
+ if (frequency === 'weekly') {
+ await db.$executeRaw`
+ SELECT cron.schedule('cleanup_cached_bank_data', '0 2 * * 0', $$
+ DELETE FROM "CachedBankData"
+ WHERE "lastFetched" < NOW() - INTERVAL '2 days'
+ $$);
+ `;
+ }
+ if (frequency === 'monthly') {
+ await db.$executeRaw`
+ SELECT cron.schedule('cleanup_cached_bank_data', '0 2 1 * *', $$
+ DELETE FROM "CachedBankData"
+ WHERE "lastFetched" < NOW() - INTERVAL '2 days'
+ $$);
+ `;
+ }
+};
diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts
index cc742257..1cb8a81e 100644
--- a/src/server/api/services/splitService.ts
+++ b/src/server/api/services/splitService.ts
@@ -41,6 +41,7 @@ export async function createExpense(
participants,
expenseDate,
fileKey,
+ transactionId,
otherConversion,
}: CreateExpense,
currentUserId: number,
@@ -67,6 +68,7 @@ export async function createExpense(
fileKey,
addedBy: currentUserId,
expenseDate,
+ transactionId,
conversionFrom: otherConversion
? {
connect: {
@@ -349,6 +351,7 @@ export async function editExpense(
participants,
expenseDate,
fileKey,
+ transactionId,
}: CreateExpense,
currentUserId: number,
) {
@@ -473,6 +476,7 @@ export async function editExpense(
create: participants,
},
fileKey,
+ transactionId,
expenseDate,
updatedBy: currentUserId,
},
@@ -729,6 +733,7 @@ export async function recalculateGroupBalances(groupId: number) {
for (const groupExpense of groupExpenses) {
for (const participant of groupExpense.expenseParticipants) {
if (participant.userId === groupExpense.paidBy) {
+ // oxlint-disable-next-line no-continue
continue;
}
@@ -792,12 +797,14 @@ export async function importUserBalanceFromSplitWise(
for (const user of splitWiseUsers) {
const dbUser = userMap[user.email];
if (!dbUser) {
+ // oxlint-disable-next-line no-continue
continue;
}
for (const balance of user.balance) {
const amount = toSafeBigInt(balance.amount);
const currency = balance.currency_code;
+ // oxlint-disable-next-line no-await-in-loop
const existingBalance = await db.balance.findUnique({
where: {
userId_currency_friendId: {
@@ -809,6 +816,7 @@ export async function importUserBalanceFromSplitWise(
});
if (existingBalance?.importedFromSplitwise) {
+ // oxlint-disable-next-line no-continue
continue;
}
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 83149554..b4847f38 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -2,7 +2,7 @@ import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { type GetServerSidePropsContext } from 'next';
import type { User } from 'next-auth';
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
-import { type Adapter, type AdapterUser, type AdapterAccount } from 'next-auth/adapters';
+import { type Adapter, type AdapterAccount, type AdapterUser } from 'next-auth/adapters';
import AuthentikProvider from 'next-auth/providers/authentik';
import EmailProvider from 'next-auth/providers/email';
import GoogleProvider from 'next-auth/providers/google';
@@ -26,6 +26,8 @@ declare module 'next-auth' {
user: DefaultSession['user'] & {
id: number;
currency: string;
+ obapiProviderId?: string;
+ bankingId?: string;
preferredLanguage: string;
// ...other properties
// role: UserRole;
@@ -34,7 +36,12 @@ declare module 'next-auth' {
interface User {
id: number;
+ name: string;
+ email: string;
+ image: string;
currency: string;
+ obapiProviderId?: string;
+ bankingId?: string;
preferredLanguage: string;
}
}
@@ -104,6 +111,8 @@ export const authOptions: NextAuthOptions = {
...session.user,
id: user.id,
currency: user.currency,
+ obapiProviderId: user.obapiProviderId,
+ bankingId: user.bankingId,
preferredLanguage: user.preferredLanguage,
},
}),
diff --git a/src/server/bankTransactionHelper.ts b/src/server/bankTransactionHelper.ts
new file mode 100644
index 00000000..01f1f329
--- /dev/null
+++ b/src/server/bankTransactionHelper.ts
@@ -0,0 +1,15 @@
+import { env } from '~/env';
+
+export type BankProviders = 'GOCARDLESS' | 'PLAID';
+
+export const isBankConnectionConfigured = () => !!whichBankConnectionConfigured();
+
+export const whichBankConnectionConfigured = (): BankProviders | null => {
+ if (env.GOCARDLESS_SECRET_ID && env.GOCARDLESS_SECRET_KEY && env.GOCARDLESS_COUNTRY) {
+ return 'GOCARDLESS';
+ }
+ if (env.PLAID_CLIENT_ID && env.PLAID_SECRET) {
+ return 'PLAID';
+ }
+ return null;
+};
diff --git a/src/server/db.ts b/src/server/db.ts
index 6e4e7344..d8e6811f 100644
--- a/src/server/db.ts
+++ b/src/server/db.ts
@@ -2,16 +2,20 @@ import { PrismaClient } from '@prisma/client';
import { env } from '~/env';
-const globalForPrisma = globalThis as unknown as {
- prisma: PrismaClient | undefined;
-};
+declare namespace globalThis {
+ // oxlint-disable-next-line no-unused-vars
+ let prisma: PrismaClient | undefined;
+}
export const db =
- globalForPrisma.prisma ??
- new PrismaClient({
- log: 'development' === env.NODE_ENV ? ['error', 'warn'] : ['error'],
- });
+ globalThis.prisma ??
+ (await (async () => {
+ const prisma = new PrismaClient({
+ log: 'development' === env.NODE_ENV ? ['error', 'warn'] : ['error'],
+ });
+ return prisma;
+ })());
if ('production' !== env.NODE_ENV) {
- globalForPrisma.prisma = db;
+ globalThis.prisma = db;
}
diff --git a/src/store/addStore.test.ts b/src/store/addStore.test.ts
index 62c87ffb..bdc78691 100644
--- a/src/store/addStore.test.ts
+++ b/src/store/addStore.test.ts
@@ -21,6 +21,8 @@ const createMockUser = (id: number, name: string, email: string): User => ({
emailVerified: null,
image: null,
preferredLanguage: 'en',
+ obapiProviderId: null,
+ bankingId: null,
});
const user1: User = createMockUser(1, 'Alice', 'alice@example.com');
diff --git a/src/store/addStore.ts b/src/store/addStore.ts
index 4c62e3d7..bbf01986 100644
--- a/src/store/addStore.ts
+++ b/src/store/addStore.ts
@@ -4,6 +4,7 @@ import { create } from 'zustand';
import { DEFAULT_CATEGORY } from '~/lib/category';
import { type CurrencyCode } from '~/lib/currency';
+import type { TransactionAddInputModel } from '~/types';
import { shuffleArray } from '~/utils/array';
import { BigMath } from '~/utils/numbers';
@@ -30,6 +31,9 @@ export interface AddExpenseState {
canSplitScreenClosed: boolean;
splitScreenOpen: boolean;
expenseDate: Date | undefined;
+ transactionId?: string;
+ multipleTransactions: TransactionAddInputModel[];
+ isTransactionLoading: boolean;
actions: {
setAmount: (amount: bigint) => void;
setAmountStr: (amountStr: string) => void;
@@ -51,6 +55,9 @@ export interface AddExpenseState {
resetState: () => void;
setSplitScreenOpen: (splitScreenOpen: boolean) => void;
setExpenseDate: (expenseDate: Date | undefined) => void;
+ setTransactionId: (transactionId?: string) => void;
+ setMultipleTransactions: (multipleTransactions: TransactionAddInputModel[]) => void;
+ setIsTransactionLoading: (isTransactionLoading: boolean) => void;
};
}
@@ -81,6 +88,8 @@ export const useAddExpenseStore = create()((set) => ({
canSplitScreenClosed: true,
splitScreenOpen: false,
expenseDate: undefined,
+ multipleTransactions: [],
+ isTransactionLoading: false,
actions: {
setAmount: (realAmount) =>
set((s) => {
@@ -112,10 +121,10 @@ export const useAddExpenseStore = create()((set) => ({
})),
setSplitShare: (splitType, userId, share) =>
set((state) => {
- const splitShares = {
+ const splitShares: SplitShares = {
...state.splitShares,
[userId]: {
- ...state.splitShares[userId],
+ ...(state.splitShares[userId] ?? initSplitShares()),
[splitType]: share,
},
} as SplitShares;
@@ -162,7 +171,6 @@ export const useAddExpenseStore = create()((set) => ({
res[p.id] = initSplitShares();
return res;
}, {});
-
if (splitType) {
calculateSplitShareBasedOnAmount(
state.amount,
@@ -274,6 +282,9 @@ export const useAddExpenseStore = create()((set) => ({
},
setSplitScreenOpen: (splitScreenOpen) => set({ splitScreenOpen }),
setExpenseDate: (expenseDate) => set({ expenseDate }),
+ setTransactionId: (transactionId) => set({ transactionId }),
+ setMultipleTransactions: (multipleTransactions) => set({ multipleTransactions }),
+ setIsTransactionLoading: (isTransactionLoading) => set({ isTransactionLoading }),
},
}));
diff --git a/src/types.ts b/src/types.ts
index 26ddf1f4..3e3f82b3 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -36,6 +36,15 @@ export interface SplitwiseGroup {
members: SplitwiseUser[];
}
+export interface TransactionAddInputModel {
+ date: Date;
+ description: string;
+ amount: string;
+ currency: string;
+ transactionId?: string;
+ expenseId?: string;
+}
+
const SplitwisePictureSchema = z.object({
small: z.string(),
medium: z.string(),
diff --git a/src/types/bank.types.ts b/src/types/bank.types.ts
new file mode 100644
index 00000000..65b4b606
--- /dev/null
+++ b/src/types/bank.types.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod';
+
+export const InstitutionsOutput = z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ logo: z.string(),
+ }),
+);
+
+export const TransactionOutputItem = z.object({
+ transactionId: z.string(),
+ bookingDate: z.string(),
+ description: z.string(),
+ transactionAmount: z.object({
+ amount: z.string(),
+ currency: z.string(),
+ }),
+});
+
+export type TransactionOutputItem = z.infer;
+
+export const TransactionOutput = z.object({
+ transactions: z.object({
+ booked: z.array(TransactionOutputItem),
+ pending: z.array(TransactionOutputItem),
+ }),
+});
+
+export type TransactionOutput = z.infer;
diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts
index 9537c59f..d80118c1 100644
--- a/src/types/expense.types.ts
+++ b/src/types/expense.types.ts
@@ -12,11 +12,13 @@ export type CreateExpense = Omit<
| 'deletedBy'
| 'expenseDate'
| 'fileKey'
+ | 'transactionId'
| 'otherConversion'
> & {
expenseDate?: Date;
fileKey?: string;
expenseId?: string;
+ transactionId?: string;
otherConversion?: string;
participants: Omit[];
};
@@ -39,6 +41,7 @@ export const createExpenseSchema = z.object({
currency: z.string(),
participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })),
fileKey: z.string().optional(),
+ transactionId: z.string().optional(),
expenseDate: z.date().optional(),
expenseId: z.string().optional(),
otherConversion: z.string().optional(),