diff --git a/.env.example b/.env.example index 71c7208b..7c8ec57b 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,26 @@ EMAIL_SERVER_PORT= EMAIL_SERVER_USER= EMAIL_SERVER_PASSWORD= +# GoCardless options +GOCARDLESS_COUNTRY= +GOCARDLESS_SECRET_ID= +GOCARDLESS_SECRET_KEY= +# Bank Transactions will be fetched from today and 30 days back as default. +GOCARDLESS_INTERVAL_IN_DAYS= + +# Plaid options +PLAID_CLIENT_ID= +PLAID_SECRET= +# sandbox/development/production +PLAID_ENVIRONMENT= +# https://plaid.com/docs/institutions/ +PLAID_COUNTRY_CODES= +# Bank Transactions will be fetched from today and 30 days back as default. +PLAID_INTERVAL_IN_DAYS= + +# Cron-job options +CLEAR_BANK_CACHE_FREQUENCY= + # Google Provider : https://next-auth.js.org/providers/google GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= diff --git a/.husky/pre-commit b/.husky/pre-commit index d7667490..ae3950b2 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,2 @@ pnpm lint-staged -pnpm tsgo --noEmit \ No newline at end of file +pnpm tsgo --noEmit diff --git a/.nvmrc b/.nvmrc index 156ca6d3..5b540673 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.16.0 \ No newline at end of file +22.16.0 diff --git a/.oxlintrc.json b/.oxlintrc.json index 4b465d64..8009e24f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -32,6 +32,7 @@ "prefer-default-export": "off", "yoda": "off", "new-cap": "off", + "no-named-export": "off", "id-length": "off", "react_perf/jsx-no-jsx-as-prop": "off", "no-nested-ternary": "off", diff --git a/README.md b/README.md index 791c74ee..9c70a941 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ pnpm i - Copy the env.example file into .env - Setup google oauth required for auth https://next-auth.js.org/providers/google or Email provider by setting SMTP details - Login to minio console using `splitpro` user and password `password` and [create access keys](http://localhost:9001/access-keys/new-account) and the R2 related env variables +- If you want to use bank integration please create a free account on [GoCardless](https://gocardless.com/bank-account-data/) or [Plaid](https://plaid.com) and then enter the the related env variables, read more in the README_BANKTRANSACTIONS.md` file. ### Run the app diff --git a/README_BANKTRANSACTIONS.md b/README_BANKTRANSACTIONS.md new file mode 100644 index 00000000..43926172 --- /dev/null +++ b/README_BANKTRANSACTIONS.md @@ -0,0 +1,15 @@ +## Bank Account Connection + +> GoCardless is deprecated. Please use Plaid instead. + +### To get started with Plaid: + +1. Create an account on Plaid. +2. Obtain your API keys (client_id and secret). +3. Add the API keys as environment variables in the split-pro repo. See the example in .env.example. +4. You’re done! ✅ + +### How to use the bank connection: + +1. Connect your bank provider to your split-pro account. Log in to split-pro, go to your account page. If Plaid is set up correctly according to the steps above, you will see a “Connect to bank” option on the account page. +2. Once the connection is complete, you can start adding bank transactions. Go to Add new expense in either a group or friend page. There you will find “Transactions”. Click it, select the transactions you want to add, and then click “Submit all.” diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml index f601ba7b..13f4be91 100644 --- a/docker/dev/compose.yml +++ b/docker/dev/compose.yml @@ -2,7 +2,7 @@ name: split-pro-dev services: postgres: - image: postgres:16 + image: ossapps/postgres container_name: ${POSTGRES_CONTAINER_NAME:-splitpro-db} restart: always environment: @@ -14,6 +14,11 @@ services: - database:/var/lib/postgresql/data ports: - '${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}' + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB:-splitpro} + -c cron.timezone=${TZ:-UTC} minio: image: minio/minio diff --git a/docker/prod/compose.yml b/docker/prod/compose.yml index e49547ca..c68c9f67 100644 --- a/docker/prod/compose.yml +++ b/docker/prod/compose.yml @@ -2,7 +2,7 @@ name: split-pro-prod services: postgres: - image: postgres:16 + image: ossapps/postgres container_name: ${POSTGRES_CONTAINER_NAME:-splitpro-db} restart: always environment: @@ -20,6 +20,11 @@ services: env_file: .env volumes: - database:/var/lib/postgresql/data + command: > + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${POSTGRES_DB:-splitpro} + -c cron.timezone=${TZ:-UTC} splitpro: image: ossapps/splitpro:latest diff --git a/package.json b/package.json index 85ebc1e1..4493e1fc 100644 --- a/package.json +++ b/package.json @@ -52,16 +52,19 @@ "lucide-react": "^0.544.0", "nanoid": "^5.0.6", "next": "15.4.7", - "next-auth": "^4.24.5", + "next-auth": "^4.24.11", "next-i18next": "^15.4.2", "next-themes": "^0.2.1", "nodemailer": "^6.9.8", + "nordigen-node": "^1.4.1", + "plaid": "^38.1.0", "radix-ui": "^1.4.2", "react": "19.1.1", "react-day-picker": "^9.7.0", "react-dom": "19.1.1", "react-hook-form": "^7.50.1", "react-i18next": "^15.5.3", + "react-plaid-link": "4.1.1", "sonner": "^1.4.0", "superjson": "^2.2.1", "vaul": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6a9a4aa..6049440c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: version: 13.7.0(react@19.1.1) '@next-auth/prisma-adapter': specifier: ^1.0.7 - version: 1.0.7(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2))(next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) + version: 1.0.7(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2))(next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) '@prisma/client': specifier: ^6.16.2 version: 6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2) @@ -91,8 +91,8 @@ importers: specifier: 15.4.7 version: 15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-auth: - specifier: ^4.24.5 - version: 4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + specifier: ^4.24.11 + version: 4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) next-i18next: specifier: ^15.4.2 version: 15.4.2(i18next@25.2.1(typescript@5.7.2))(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-i18next@15.5.3(i18next@25.2.1(typescript@5.7.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.7.2))(react@19.1.1) @@ -101,7 +101,13 @@ importers: version: 0.2.1(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) nodemailer: specifier: ^6.9.8 - version: 6.10.1 + version: 6.9.8 + nordigen-node: + specifier: ^1.4.1 + version: 1.4.1 + plaid: + specifier: ^38.1.0 + version: 38.1.0 radix-ui: specifier: ^1.4.2 version: 1.4.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -120,6 +126,9 @@ importers: react-i18next: specifier: ^15.5.3 version: 15.5.3(i18next@25.2.1(typescript@5.7.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.7.2) + react-plaid-link: + specifier: 4.1.1 + version: 4.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) sonner: specifier: ^1.4.0 version: 1.7.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -3234,6 +3243,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -3242,6 +3254,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.7.7: + resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} + babel-jest@30.0.5: resolution: {integrity: sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3454,6 +3469,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.0: resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} engines: {node: '>=20'} @@ -3574,6 +3593,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -3596,6 +3619,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dotenv@10.0.0: + resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} + engines: {node: '>=10'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3795,6 +3822,15 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -3803,6 +3839,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.1: + resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} + engines: {node: '>= 6'} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -4512,6 +4552,10 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4704,10 +4748,13 @@ packages: node-releases@2.0.21: resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} - nodemailer@6.10.1: - resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + nodemailer@6.9.8: + resolution: {integrity: sha512-cfrYUk16e67Ks051i4CntM9kshRYei1/o/Gi8K1d+R34OIs21xdFnW7Pt7EucmVKA0LKtqUGNcjMZ7ehjl49mQ==} engines: {node: '>=6.0.0'} + nordigen-node@1.4.1: + resolution: {integrity: sha512-m7U9scf9GEkLEwy03l+cxcfcDGAiRMtD1sWrEH/mpYrZEC1hDZ2wRg+eMndzDOnADZaUoPvcimushUUA4+csvg==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4727,6 +4774,10 @@ packages: oauth@0.9.15: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-hash@2.2.0: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} @@ -4860,6 +4911,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + plaid@38.1.0: + resolution: {integrity: sha512-5CxHxiTEJUEohvxOhfNclWIrIKJYN6pODBoF7X6Iwr9nYYUsk2XsYzOMUMBybAFRTcanU2C2F5STPmsNxgT0PQ==} + engines: {node: '>=10.0.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4965,6 +5020,12 @@ packages: typescript: optional: true + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5036,6 +5097,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-plaid-link@4.1.1: + resolution: {integrity: sha512-xzAYWQIT/gk+u6lwFAMEZ20f9+AUsCwVyfm64/iudMsyuWANta4wm3Jb7N+APSwuKIR9VUlTkYDhPjLamIGcPA==} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -7705,10 +7772,10 @@ snapshots: '@tybys/wasm-util': 0.10.0 optional: true - '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2))(next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))': + '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2))(next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))': dependencies: '@prisma/client': 6.16.2(prisma@6.16.2(typescript@5.7.2))(typescript@5.7.2) - next-auth: 4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + next-auth: 4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@next/env@15.4.7': {} @@ -9490,12 +9557,22 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + at-least-node@1.0.0: {} available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 + axios@1.7.7: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.1 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-jest@30.0.5(@babel/core@7.28.3): dependencies: '@babel/core': 7.28.3 @@ -9743,6 +9820,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.0: {} commander@2.20.3: {} @@ -9845,6 +9926,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + destr@2.0.5: {} detect-libc@2.0.4: {} @@ -9857,6 +9940,8 @@ snapshots: diff@4.0.2: {} + dotenv@10.0.0: {} + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -10122,6 +10207,8 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + follow-redirects@1.15.9: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -10131,6 +10218,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.1: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -11091,6 +11184,10 @@ snapshots: strip-ansi: 7.1.0 wrap-ansi: 9.0.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@10.4.3: {} lru-cache@5.1.1: @@ -11182,9 +11279,9 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.10.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + next-auth@4.24.11(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(nodemailer@6.9.8)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: - '@babel/runtime': 7.27.1 + '@babel/runtime': 7.27.6 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 @@ -11197,7 +11294,7 @@ snapshots: react-dom: 19.1.1(react@19.1.1) uuid: 8.3.2 optionalDependencies: - nodemailer: 6.10.1 + nodemailer: 6.9.8 next-i18next@15.4.2(i18next@25.2.1(typescript@5.7.2))(next@15.4.7(@babel/core@7.27.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-i18next@15.5.3(i18next@25.2.1(typescript@5.7.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.7.2))(react@19.1.1): dependencies: @@ -11252,7 +11349,14 @@ snapshots: node-releases@2.0.21: {} - nodemailer@6.10.1: {} + nodemailer@6.9.8: {} + + nordigen-node@1.4.1: + dependencies: + axios: 1.7.7 + dotenv: 10.0.0 + transitivePeerDependencies: + - debug normalize-path@3.0.0: {} @@ -11272,6 +11376,8 @@ snapshots: oauth@0.9.15: {} + object-assign@4.1.1: {} + object-hash@2.2.0: {} object-inspect@1.13.4: {} @@ -11401,6 +11507,12 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + plaid@38.1.0: + dependencies: + axios: 1.7.7 + transitivePeerDependencies: + - debug + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -11453,6 +11565,14 @@ snapshots: transitivePeerDependencies: - magicast + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -11563,6 +11683,12 @@ snapshots: react-is@18.3.1: {} + react-plaid-link@4.1.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + prop-types: 15.8.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.1): dependencies: react: 19.1.1 diff --git a/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql b/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql new file mode 100644 index 00000000..7760d104 --- /dev/null +++ b/prisma/migrations/20241026095834_add_gocardless_bank_transaction_integration/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "transactionId" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "bankingId" TEXT, +ADD COLUMN "obapiProviderId" TEXT; + +-- CreateTable +CREATE TABLE "CachedBankData" ( + "id" SERIAL NOT NULL, + "obapiProviderId" TEXT NOT NULL, + "data" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "lastFetched" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CachedBankData_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CachedBankData_obapiProviderId_key" ON "CachedBankData"("obapiProviderId"); + +-- AddForeignKey +ALTER TABLE "public"."CachedBankData" ADD CONSTRAINT "CachedBankData_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7908ec0c..d3e94769 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,8 @@ model User { id Int @id @default(autoincrement()) name String? email String? @unique + obapiProviderId String? + bankingId String? emailVerified DateTime? image String? currency String @default("USD") @@ -57,6 +59,7 @@ model User { expenseParticipants ExpenseParticipant[] expenseNotes ExpenseNote[] userBalances Balance[] @relation("UserBalance") + cachedBankData CachedBankData[] @relation("UserCachedBankData") friendBalances Balance[] @relation("FriendBalance") groupUserBalances GroupBalance[] @relation("GroupUserBalance") groupFriendBalances GroupBalance[] @relation("GroupFriendBalance") @@ -66,6 +69,17 @@ model User { updatedExpenses Expense[] @relation("UpdatedByUser") } +model CachedBankData { + id Int @id @default(autoincrement()) + obapiProviderId String @unique + data String + userId Int + user User @relation(name: "UserCachedBankData", fields: [userId], references: [id], onDelete: Cascade) + lastFetched DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model VerificationToken { identifier String token String @unique @@ -165,6 +179,7 @@ model Expense { conversionFrom Expense? @relation(name: "CurrencyConversion") expenseParticipants ExpenseParticipant[] expenseNotes ExpenseNote[] + transactionId String? @@index([groupId]) @@index([paidBy]) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index c77fa8df..b0745837 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -70,7 +70,9 @@ "leave": "Leave", "save": "Save", "settle_up": "Settle up", - "submit": "Submit" + "submit": "Submit", + "reconnect": "Reconnect", + "connect": "Connect" }, "actors": { "all": "All", @@ -126,6 +128,18 @@ "uploading_error": "Error uploading file", "valid_email": "Enter valid email" }, + "bank_transactions": { + "choose_bank_provider": "Choose bank provider", + "select_bank_provider": "Select bank provider", + "search_bank": "Search bank", + "no_bank_providers_found": "No bank providers found", + "to_bank": "to bank", + "plaid": { + "bank_connected_successfully": "Bank connected successfully", + "bank_connection_failed": "Failed to connect to bank", + "bank_connection_cancelled": "Bank connection cancelled" + } + }, "expense_details": { "add_expense": "Add expense", "add_expense_details": { @@ -190,7 +204,13 @@ "title": "Are you absolutely sure?" }, "title": "Add expense", - "title_mobile": "Add" + "title_mobile": "Add", + "clear": "Clear", + "bank_transactions": "Bank transactions", + "submit_all": "Submit all", + "pending": "Pending", + "no_transactions_yet": "No transactions yet", + "already_added": "Already added" }, "group_details": { "add_members": "Add members", diff --git a/public/locales/en/home.json b/public/locales/en/home.json index c67219dc..f1ea32c7 100644 --- a/public/locales/en/home.json +++ b/public/locales/en/home.json @@ -16,6 +16,10 @@ "description": "Available in multiple languages to make expense splitting accessible worldwide", "title": "Internationalization" }, + "bank_connection": { + "title": "Bank connection", + "description": "Add expenses faster with a integration to GoCardless or Plaid bank account transactions api" + }, "import_splitwise": { "description": "Don't have to manually migrate balances. You can import users and groups from splitwise", "title": "Import from splitwise" diff --git a/src/components/Account/BankAccount/BankAccountSelect.tsx b/src/components/Account/BankAccount/BankAccountSelect.tsx new file mode 100644 index 00000000..bd62ca94 --- /dev/null +++ b/src/components/Account/BankAccount/BankAccountSelect.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../../ui/command'; +import { AppDrawer } from '../../ui/drawer'; +import { Check, ChevronRight, Landmark } from 'lucide-react'; +import { cn } from '~/lib/utils'; +import { api } from '~/utils/api'; +import { Button } from '../../ui/button'; +import Image from 'next/image'; +import { useTranslation } from 'next-i18next'; + +export const BankAccountSelect = ({ + bankConnectionEnabled, +}: { + bankConnectionEnabled: boolean; +}) => { + const { t } = useTranslation(); + + const userQuery = api.user.me.useQuery(); + const updateProfile = api.user.updateUserDetail.useMutation(); + const institutions = api.bankTransactions.getInstitutions.useQuery(); + + if (!bankConnectionEnabled) { + return null; + } + + const onSelect = useCallback( + async (currentValue: string) => { + await updateProfile.mutateAsync({ bankingId: currentValue.toUpperCase() }); + userQuery.refetch().catch((err) => console.error(err)); + }, + [updateProfile, userQuery], + ); + + return ( + +
+ +

{t('bank_transactions.choose_bank_provider')}

+
+ + + } + title={t('bank_transactions.select_bank_provider')} + className="h-[70vh]" + > + + + {t('bank_transactions.no_bank_providers_found')} + + {institutions?.data?.map((framework) => ( + + +
+ {framework.logo && ( + {framework.name} + )} +

{framework.name}

+
+
+ ))} +
+
+
+ ); +}; diff --git a/src/components/Account/BankAccount/BankConnection.tsx b/src/components/Account/BankAccount/BankConnection.tsx new file mode 100644 index 00000000..4854fab7 --- /dev/null +++ b/src/components/Account/BankAccount/BankConnection.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from 'react'; +import { BankAccountSelect } from './BankAccountSelect'; +import { PlaidLink } from './PlaidLink'; +import { api } from '~/utils/api'; +import type { ButtonProps } from '~/components/ui/button'; + +interface BankConnectionProps { + bankConnectionEnabled: boolean; + bankConnection: string | null; + children: React.ReactElement; +} + +export const BankConnection: React.FC = ({ + bankConnectionEnabled, + bankConnection, + children, +}) => { + const userQuery = api.user.me.useQuery(); + const connectToBank = api.bankTransactions.connectToBank.useMutation(); + + const onConnectToBank = useCallback(async () => { + if (bankConnection === 'GOCARDLESS') { + if (!userQuery.data?.bankingId) { + return; + } + const res = await connectToBank.mutateAsync(userQuery.data.bankingId); + if (res?.authLink) { + window.location.href = res.authLink; + } + } else if (bankConnection === 'PLAID') { + const res = await connectToBank.mutateAsync(); + if (res?.authLink) { + return res.authLink; + } + } + }, [connectToBank, userQuery.data?.bankingId, bankConnection]); + + const fetchUser = useCallback(() => { + userQuery.refetch().catch(console.error); + }, [userQuery]); + + if (!bankConnectionEnabled) { + return null; + } + + return ( + <> + {bankConnection === 'GOCARDLESS' && ( + + )} + + {bankConnection === 'PLAID' ? ( + + {children} + + ) : ( + bankConnection === 'GOCARDLESS' && + userQuery.data?.bankingId && + React.cloneElement(children, { onClick: onConnectToBank } as Partial) + )} + + ); +}; diff --git a/src/components/Account/BankAccount/PlaidLink.tsx b/src/components/Account/BankAccount/PlaidLink.tsx new file mode 100644 index 00000000..9debdd8f --- /dev/null +++ b/src/components/Account/BankAccount/PlaidLink.tsx @@ -0,0 +1,85 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { usePlaidLink } from 'react-plaid-link'; +import { api } from '~/utils/api'; +import { toast } from 'sonner'; +import { useTranslation } from 'next-i18next'; +import type { ButtonProps } from '~/components/ui/button'; + +interface PlaidLinkProps { + onConnect: () => Promise; + onSuccess?: () => void; + children: React.ReactElement; +} + +const usePlaidLinkHook = ( + onConnect: () => Promise, + onPlaidSuccess: (publicToken: string, _metadata: any) => void, + onPlaidExit: (err: any, _metadata: any) => void, +): { open: () => void; ready: boolean } => { + const [linkToken, setLinkToken] = useState(null); + const requestedRef = useRef(false); + const { open, ready } = usePlaidLink({ + token: linkToken, + onSuccess: onPlaidSuccess, + onExit: onPlaidExit, + }); + + useEffect(() => { + const preFunction = async () => { + const authToken = await onConnect(); + if (authToken) { + setLinkToken(authToken); + } + }; + + if (!linkToken && !requestedRef.current) { + requestedRef.current = true; + void preFunction(); + } + }, [linkToken, onConnect]); + + return { + open: open as () => void, + ready, + }; +}; + +export const PlaidLink: React.FC = ({ onConnect, onSuccess, children }) => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const exchangePublicToken = api.bankTransactions.exchangePublicToken.useMutation(); + + const onPlaidSuccess = useCallback( + async (publicToken: string, _metadata: any) => { + setIsLoading(true); + try { + await exchangePublicToken.mutateAsync(publicToken); + toast.success(t('bank_transactions.plaid.bank_connected_successfully')); + onSuccess?.(); + } catch (error) { + console.error('Error exchanging public token:', error); + toast.error(t('bank_transactions.plaid.bank_connection_failed')); + } finally { + setIsLoading(false); + } + }, + [exchangePublicToken, onSuccess, t], + ); + + const onPlaidExit = useCallback( + (err: any, _metadata: any) => { + if (err) { + console.error('Plaid Link error:', err); + toast.error(t('bank_transactions.plaid.bank_connection_cancelled')); + } + }, + [t], + ); + + const { open, ready } = usePlaidLinkHook(onConnect, onPlaidSuccess, onPlaidExit); + + return React.cloneElement(children, { + onClick: open, + disabled: !ready || isLoading || (children.props as ButtonProps).disabled, + } as Partial); +}; diff --git a/src/components/AddExpense/AddBankTransactions.tsx b/src/components/AddExpense/AddBankTransactions.tsx new file mode 100644 index 00000000..7c588bea --- /dev/null +++ b/src/components/AddExpense/AddBankTransactions.tsx @@ -0,0 +1,155 @@ +import React, { useCallback } from 'react'; +import { useAddExpenseStore } from '~/store/addStore'; +import type { TransactionAddInputModel } from '~/types'; +import { BankingTransactionList } from './BankTransactions/BankingTransactionList'; +import { isCurrencyCode } from '~/lib/currency'; + +const AddBankTransactions: React.FC<{ + // clearFields: () => void; + onUpdateAmount: (amount: string) => void; + bankConnectionEnabled: boolean; + children: React.ReactNode; +}> = ({ bankConnectionEnabled, onUpdateAmount, children }) => { + // const participants = useAddExpenseStore((s) => s.participants); + // const group = useAddExpenseStore((s) => s.group); + // const category = useAddExpenseStore((s) => s.category); + // const isExpenseSettled = useAddExpenseStore((s) => s.canSplitScreenClosed); + // const splitShares = useAddExpenseStore((s) => s.splitShares); + // const paidBy = useAddExpenseStore((s) => s.paidBy); + // const splitType = useAddExpenseStore((s) => s.splitType); + // const fileKey = useAddExpenseStore((s) => s.fileKey); + // const multipleTransactions = useAddExpenseStore((s) => s.multipleTransactions); + // const isTransactionLoading = useAddExpenseStore((s) => s.isTransactionLoading); + + const { + setCurrency, + setDescription, + // resetState, + // setSplitScreenOpen, + setExpenseDate, + setTransactionId, + // setMultipleTransactions, + // setIsTransactionLoading, + } = useAddExpenseStore((s) => s.actions); + + // const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); + + // const router = useRouter(); + + // const addMultipleExpenses = useCallback(async () => { + // setIsTransactionLoading(true); + + // if (!paidBy) { + // return; + // } + + // if (!isExpenseSettled) { + // setSplitScreenOpen(true); + // return; + // } + + // const seen = new Set(); + // const deduplicated = multipleTransactions.filter((item) => { + // if (seen.has(item.transactionId)) { + // return false; + // } + // seen.add(item.transactionId); + // return true; + // }); + + // const expensePromises = deduplicated.map(async (tempItem) => { + // if (tempItem) { + // const normalizedAmount = tempItem.amount.replace(',', '.'); + // const _amtBigInt = BigInt(Math.round(Number(normalizedAmount) * 100)); + + // const { participants: tempParticipants } = calculateParticipantSplit( + // _amtBigInt, + // participants, + // splitType, + // splitShares, + // paidBy, + // ); + + // return addExpenseMutation.mutateAsync({ + // name: tempItem.description, + // currency: tempItem.currency, + // amount: _amtBigInt, + // groupId: group?.id ?? null, + // splitType, + // participants: tempParticipants.map((p) => ({ + // userId: p.id, + // amount: p.amount ?? 0n, + // })), + // paidBy: paidBy.id, + // category, + // fileKey, + // expenseDate: tempItem.date, + // expenseId: tempItem.expenseId, + // transactionId: tempItem.transactionId, + // }); + // } + // return Promise.resolve(); + // }); + + // await Promise.all(expensePromises); + + // setMultipleTransactions([]); + // setIsTransactionLoading(false); + // router.back(); + // resetState(); + // }, [ + // setSplitScreenOpen, + // router, + // resetState, + // addExpenseMutation, + // group, + // paidBy, + // splitType, + // fileKey, + // isExpenseSettled, + // multipleTransactions, + // participants, + // category, + // setIsTransactionLoading, + // splitShares, + // setMultipleTransactions, + // ]); + + const addViaBankTransaction = useCallback( + (obj: TransactionAddInputModel) => { + setExpenseDate(obj.date); + setDescription(obj.description); + if (isCurrencyCode(obj.currency)) { + setCurrency(obj.currency); + } else { + console.warn(`Invalid currency code: ${obj.currency}`); + } + onUpdateAmount(obj.amount); + setTransactionId(obj.transactionId); + }, + [setExpenseDate, setDescription, setCurrency, onUpdateAmount, setTransactionId], + ); + + // const handleSetMultipleTransactions = useCallback( + // (a: TransactionAddInputModel[]) => { + // setMultipleTransactions(a); + // }, + // [setMultipleTransactions], + // ); + + return ( + + {children} + + ); +}; + +export default AddBankTransactions; diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 08b51f60..a8c6afa4 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -1,4 +1,4 @@ -import { HeartHandshakeIcon } from 'lucide-react'; +import { HeartHandshakeIcon, Landmark, X } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -11,8 +11,12 @@ import { currencyConversion, toSafeBigInt, toUIString } from '~/utils/numbers'; import { toast } from 'sonner'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cn } from '~/lib/utils'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { Button } from '../ui/button'; +import { CURRENCY_CONVERSION_ICON } from '../ui/categoryIcons'; import { Input } from '../ui/input'; +import AddBankTransactions from './AddBankTransactions'; import { CategoryPicker } from './CategoryPicker'; import { CurrencyPicker } from './CurrencyPicker'; import { DateSelector } from './DateSelector'; @@ -20,14 +24,13 @@ import { SelectUserOrGroup } from './SelectUserOrGroup'; import { SplitTypeSection } from './SplitTypeSection'; import { UploadFile } from './UploadFile'; import { UserInput } from './UserInput'; -import { CurrencyConversion } from '../Friend/CurrencyConversion'; -import { CURRENCY_CONVERSION_ICON } from '../ui/categoryIcons'; export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; enableSendingInvites: boolean; expenseId?: string; -}> = ({ isStorageConfigured, enableSendingInvites, expenseId }) => { + bankConnectionEnabled: boolean; +}> = ({ isStorageConfigured, enableSendingInvites, expenseId, bankConnectionEnabled }) => { const { t } = useTranslationWithUtils(); const showFriends = useAddExpenseStore((s) => s.showFriends); const amount = useAddExpenseStore((s) => s.amount); @@ -44,6 +47,7 @@ export const AddOrEditExpensePage: React.FC<{ const paidBy = useAddExpenseStore((s) => s.paidBy); const splitType = useAddExpenseStore((s) => s.splitType); const fileKey = useAddExpenseStore((s) => s.fileKey); + const transactionId = useAddExpenseStore((s) => s.transactionId); const { setCurrency, @@ -54,6 +58,9 @@ export const AddOrEditExpensePage: React.FC<{ resetState, setSplitScreenOpen, setExpenseDate, + setTransactionId, + setMultipleTransactions, + setIsTransactionLoading, } = useAddExpenseStore((s) => s.actions); const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); @@ -90,6 +97,9 @@ export const AddOrEditExpensePage: React.FC<{ return; } + setMultipleTransactions([]); + setIsTransactionLoading(false); + const sign = isNegative ? -1n : 1n; try { @@ -109,6 +119,7 @@ export const AddOrEditExpensePage: React.FC<{ fileKey, expenseDate, expenseId, + transactionId, }, { onSuccess: (d) => { @@ -148,6 +159,9 @@ export const AddOrEditExpensePage: React.FC<{ splitType, fileKey, isExpenseSettled, + setMultipleTransactions, + transactionId, + setIsTransactionLoading, ]); const handleDescriptionChange = useCallback( @@ -166,6 +180,14 @@ export const AddOrEditExpensePage: React.FC<{ [onUpdateAmount], ); + const clearFields = useCallback(() => { + setAmount(0n); + setDescription(''); + setAmountStr(''); + setTransactionId(); + setExpenseDate(new Date()); + }, [setAmount, setDescription, setAmountStr, setTransactionId, setExpenseDate]); + const previousCurrencyRef = React.useRef(null); const onConvertAmount: React.ComponentProps['onSubmit'] = useCallback( @@ -282,7 +304,35 @@ export const AddOrEditExpensePage: React.FC<{ ) : null} - +
+ {/* place for recurring button */} + +
+ + + + + +
+
)} diff --git a/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx b/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx new file mode 100644 index 00000000..618bbd94 --- /dev/null +++ b/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx @@ -0,0 +1,84 @@ +import React, { useCallback } from 'react'; +import { Button } from '../../ui/button'; +// import { Checkbox } from '../../ui/checkbox'; +// import type { TransactionAddInputModel } from '~/types'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { cn } from '~/lib/utils'; +import type { TransactionWithPendingStatus } from './BankingTransactionList'; + +export const BankTransactionItem: React.FC<{ + index: number; + alreadyAdded: boolean; + item: TransactionWithPendingStatus; + onTransactionRowClick: (item: TransactionWithPendingStatus, multiple: boolean) => void; + groupName: string; + // multipleTransactions: TransactionAddInputModel[]; +}> = ({ index, alreadyAdded, item, onTransactionRowClick, groupName }) => { + const { t, toUIDate } = useTranslationWithUtils(); + + // const createCheckboxHandler = useCallback( + // (item: TransactionWithPendingStatus) => () => onTransactionRowClick(item, true), + // [onTransactionRowClick], + // ); + + const createClickHandler = useCallback( + () => onTransactionRowClick(item, false), + [onTransactionRowClick, item], + ); + + const isNegative = item?.transactionAmount?.amount + ? Number(item.transactionAmount.amount) < 0 + : false; + + return ( +
+
+ {/* cItem.transactionId === item.transactionId, + )} + disabled={alreadyAdded} + onCheckedChange={createCheckboxHandler(item)} + className="h-6 w-6 md:h-4 md:w-4" + /> */} + +
+
+
+ {item.transactionAmount.currency}{' '} + {item.transactionAmount.amount} +
+
+
+ ); +}; diff --git a/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx new file mode 100644 index 00000000..5397bbc3 --- /dev/null +++ b/src/components/AddExpense/BankTransactions/BankingTransactionList.tsx @@ -0,0 +1,160 @@ +import React, { useCallback } from 'react'; +import { api } from '~/utils/api'; +import { LoadingSpinner } from '../../ui/spinner'; +import type { TransactionAddInputModel } from '~/types'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { BankTransactionItem } from './BankTransactionItem'; +import type { TransactionOutputItem } from '~/types/bank.types'; +import { AppDrawer } from '~/components/ui/drawer'; + +export type TransactionWithPendingStatus = TransactionOutputItem & { + pending: boolean; +}; + +export const BankingTransactionList: React.FC<{ + add: (obj: TransactionAddInputModel) => void; + // addMultipleExpenses: () => void; + // multipleTransactions: TransactionAddInputModel[]; + // setMultipleTransactions: (a: TransactionAddInputModel[]) => void; + // isTransactionLoading: boolean; + bankConnectionEnabled: boolean; + children: React.ReactNode; + // clearFields: () => void; +}> = ({ + add, + // addMultipleExpenses, + // multipleTransactions, + // setMultipleTransactions, + // isTransactionLoading, + bankConnectionEnabled, + children, + // clearFields, +}) => { + const { t } = useTranslationWithUtils(); + + const [open, setOpen] = React.useState(false); + + const userQuery = api.user.me.useQuery(); + const transactions = api.bankTransactions.getTransactions.useQuery( + userQuery.data?.obapiProviderId, + ); + + const expensesQuery = api.user.getOwnExpenses.useQuery(); + + const returnTransactionsArray = (): TransactionWithPendingStatus[] => { + const data = transactions?.data?.transactions; + + if (!data) { + return []; + } + + const mapTransactions = (items: TransactionOutputItem[], pendingStatus: boolean) => + items?.map((cItem) => ({ ...cItem, pending: pendingStatus })) || []; + + const pending = mapTransactions(data.pending, true); + const booked = mapTransactions(data.booked, false); + + return [...pending, ...booked]; + }; + + const alreadyAdded = useCallback( + (transactionId: string) => + expensesQuery?.data?.some((item) => item.transactionId === transactionId) ?? false, + [expensesQuery?.data], + ); + + const returnGroupName = (transactionId: string) => { + const transaction = expensesQuery?.data?.find((item) => item.transactionId === transactionId); + return transaction?.group?.name ? ` to ${transaction.group.name}` : ''; + }; + + if (!bankConnectionEnabled) { + return null; + } + + const transactionsArray = returnTransactionsArray(); + + const onTransactionRowClick = useCallback( + (item: TransactionWithPendingStatus) => { + const transactionData = { + date: new Date(item.bookingDate), + amount: item.transactionAmount.amount.replace('-', ''), + currency: item.transactionAmount.currency, + description: item.description, + transactionId: item.transactionId, + }; + + // if (multiple) { + // clearFields(); + // const isInMultipleTransactions = multipleTransactions?.some( + // (cItem) => cItem.transactionId === item.transactionId, + // ); + + // setMultipleTransactions( + // isInMultipleTransactions + // ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId) + // : [...multipleTransactions, transactionData], + // ); + // } else { + if (alreadyAdded(item.transactionId)) { + return; + } + add(transactionData); + setOpen(false); + document.getElementById('mainlayout')?.scrollTo({ top: 0, behavior: 'instant' }); + // } + }, + [add, alreadyAdded], + ); + + const setOpenClose = useCallback((open: boolean) => { + setOpen(open); + // if (!open) { + // setMultipleTransactions([]); + // } + }, []); + + return ( + +
+ {transactions?.isLoading ? ( +
+ +
+ ) : ( + <> + {transactionsArray?.length === 0 && ( +
+ {t('expense_details.no_transactions_yet')} +
+ )} + {transactionsArray.map((item, index) => ( + + ))} + + )} +
+
+ ); +}; diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 3d710c1c..cf0a7251 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -4,7 +4,7 @@ import { type Group, type GroupUser, type User } from '@prisma/client'; import { SendIcon } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Image from 'next/image'; -import React from 'react'; +import React, { useCallback } from 'react'; import { z } from 'zod'; import { useAddExpenseStore } from '~/store/addStore'; @@ -36,42 +36,59 @@ export const SelectUserOrGroup: React.FC<{ (f.name ?? f.email)?.toLowerCase().includes(nameOrEmail.toLowerCase()), ); - function onAddEmailClick(invite = false) { - if (isEmail.success) { - addFriendMutation.mutate( - { email: nameOrEmail, sendInviteEmail: invite }, - { - onSuccess: (user) => { - removeParticipant(-1); - addOrUpdateParticipant(user); - setNameOrEmail(''); + const onAddEmailClick = useCallback( + (invite = false) => { + if (isEmail.success) { + addFriendMutation.mutate( + { email: nameOrEmail, sendInviteEmail: invite }, + { + onSuccess: (user) => { + removeParticipant(-1); + addOrUpdateParticipant(user); + setNameOrEmail(''); + }, }, - }, - ); - addOrUpdateParticipant({ - id: -1, - name: nameOrEmail, - email: nameOrEmail, - emailVerified: new Date(), - image: null, - currency: 'USD', - preferredLanguage: '', - }); - // add email to split pro - } - } + ); + addOrUpdateParticipant({ + id: -1, + name: nameOrEmail, + email: nameOrEmail, + emailVerified: new Date(), + image: null, + currency: 'USD', + obapiProviderId: null, + bankingId: null, + preferredLanguage: '', + }); + // add email to split pro + } + }, + [ + isEmail.success, + nameOrEmail, + addFriendMutation, + addOrUpdateParticipant, + setNameOrEmail, + removeParticipant, + ], + ); - function onGroupSelect(group: Group & { groupUsers: (GroupUser & { user: User })[] }) { - setGroup(group); - const { currentUser } = useAddExpenseStore.getState(); - if (currentUser) { - setParticipants([ - currentUser, - ...group.groupUsers.map((gu) => gu.user).filter((u) => u.id !== currentUser.id), - ]); - } - setNameOrEmail(''); - } + const onGroupSelect = useCallback( + (group: Group & { groupUsers: (GroupUser & { user: User })[] }) => { + setGroup(group); + const { currentUser } = useAddExpenseStore.getState(); + if (currentUser) { + setParticipants([ + currentUser, + ...group.groupUsers.map((gu) => gu.user).filter((u) => u.id !== currentUser.id), + ]); + } + setNameOrEmail(''); + }, + [setGroup, setParticipants, setNameOrEmail], + ); + + const handleAddEmailClickFalse = useCallback(() => onAddEmailClick(false), [onAddEmailClick]); if (group) { return ( @@ -81,6 +98,20 @@ export const SelectUserOrGroup: React.FC<{ ); } + const handleFriendClick = useCallback( + (f: User) => { + const isExisting = participants.some((p) => p.id === f.id); + + if (isExisting) { + removeParticipant(f.id); + } else { + addOrUpdateParticipant(f); + } + setNameOrEmail(''); + }, + [participants, removeParticipant, addOrUpdateParticipant, setNameOrEmail], + ); + return (
@@ -101,7 +132,7 @@ export const SelectUserOrGroup: React.FC<{ className="mt-4 w-full text-cyan-500 hover:text-cyan-500" variant="outline" disabled={!isEmail.success} - onClick={() => onAddEmailClick(false)} + onClick={handleAddEmailClickFalse} > {t('expense_details.add_expense_details.select_user_or_group.send_invite')} @@ -111,7 +142,7 @@ export const SelectUserOrGroup: React.FC<{ className="mt-4 w-full text-cyan-500 hover:text-cyan-500" variant="outline" disabled={!isEmail.success} - onClick={() => onAddEmailClick(false)} + onClick={handleAddEmailClickFalse} > {t('expense_details.add_expense_details.select_user_or_group.add_to_split_pro')} @@ -123,20 +154,11 @@ export const SelectUserOrGroup: React.FC<{ <>
{t('actors.friends')}
{filteredFriends.map((f) => { - const isExisting = participants.some((p) => p.id === f.id); - return ( - ))} + {filteredGroups.map((g) => { + return ( + + ); + })}
) : null} diff --git a/src/components/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index e66466fa..dcc3cf9b 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -62,6 +62,8 @@ export const UserInput: React.FC<{ emailVerified: new Date(), image: null, currency: 'USD', + obapiProviderId: null, + bankingId: null, preferredLanguage: '', }); } diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 348f834f..bafd3812 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -17,6 +17,7 @@ import { Button } from '../ui/button'; import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; +import { Landmark } from 'lucide-react'; type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; @@ -37,7 +38,10 @@ const ExpenseDetails: React.FC = ({ user, expense, storageP
-

{expense.name}

+
+

{expense.name}

+ {expense.transactionId && } +

{expense.currency} {toUIString(expense.amount)}

diff --git a/src/components/Layout/MainLayout.tsx b/src/components/Layout/MainLayout.tsx index 33291a26..9a648baf 100644 --- a/src/components/Layout/MainLayout.tsx +++ b/src/components/Layout/MainLayout.tsx @@ -79,7 +79,10 @@ const MainLayout: React.FC = ({ currentPath={currentPath} /> -
+
{title ? (
{title}
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index b7d24a08..7fa63496 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -205,7 +205,6 @@ export const AppDrawer: React.FC = (props) => { {trigger} { if (false === dismissible) { e.preventDefault(); diff --git a/src/env.ts b/src/env.ts index ff61e814..6af83a28 100644 --- a/src/env.ts +++ b/src/env.ts @@ -27,6 +27,7 @@ export const env = createEnv({ (str) => process.env.VERCEL_URL ?? str, process.env.VERCEL ? z.string() : z.string().url(), ), + CLEAR_BANK_CACHE_FREQUENCY: z.enum(['weekly', 'monthly']).optional(), ENABLE_SENDING_INVITES: z.boolean(), DISABLE_EMAIL_SIGNUP: z.boolean(), INVITE_ONLY: z.boolean(), @@ -35,6 +36,15 @@ export const env = createEnv({ EMAIL_SERVER_PORT: z.string().optional(), EMAIL_SERVER_USER: z.string().optional(), EMAIL_SERVER_PASSWORD: z.string().optional(), + GOCARDLESS_COUNTRY: z.string().optional(), + GOCARDLESS_SECRET_ID: z.string().optional(), + GOCARDLESS_SECRET_KEY: z.string().optional(), + GOCARDLESS_INTERVAL_IN_DAYS: z.number().optional(), + PLAID_CLIENT_ID: z.string().optional(), + PLAID_SECRET: z.string().optional(), + PLAID_ENVIRONMENT: z.enum(['sandbox', 'development', 'production']).default('sandbox'), + PLAID_COUNTRY_CODES: z.string().optional(), + PLAID_INTERVAL_IN_DAYS: z.number().optional(), GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), AUTHENTIK_ID: z.string().optional(), @@ -87,6 +97,7 @@ export const env = createEnv({ NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, NEXTAUTH_URL_INTERNAL: process.env.NEXTAUTH_URL_INTERNAL ?? process.env.NEXTAUTH_URL, + CLEAR_BANK_CACHE_FREQUENCY: process.env.CLEAR_BANK_CACHE_FREQUENCY, ENABLE_SENDING_INVITES: 'true' === process.env.ENABLE_SENDING_INVITES, DISABLE_EMAIL_SIGNUP: 'true' === process.env.DISABLE_EMAIL_SIGNUP, INVITE_ONLY: 'true' === process.env.INVITE_ONLY, @@ -95,6 +106,15 @@ export const env = createEnv({ EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT, EMAIL_SERVER_USER: process.env.EMAIL_SERVER_USER, EMAIL_SERVER_PASSWORD: process.env.EMAIL_SERVER_PASSWORD, + GOCARDLESS_COUNTRY: process.env.GOCARDLESS_COUNTRY, + GOCARDLESS_SECRET_ID: process.env.GOCARDLESS_SECRET_ID, + GOCARDLESS_SECRET_KEY: process.env.GOCARDLESS_SECRET_KEY, + GOCARDLESS_INTERVAL_IN_DAYS: process.env.GOCARDLESS_INTERVAL_IN_DAYS, + PLAID_CLIENT_ID: process.env.PLAID_CLIENT_ID, + PLAID_SECRET: process.env.PLAID_SECRET, + PLAID_ENVIRONMENT: process.env.PLAID_ENVIRONMENT, + PLAID_COUNTRY_CODES: process.env.PLAID_COUNTRY_CODES, + PLAID_INTERVAL_IN_DAYS: process.env.PLAID_INTERVAL_IN_DAYS, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, AUTHENTIK_ID: process.env.AUTHENTIK_ID, diff --git a/src/hooks/useTranslationWithUtils.ts b/src/hooks/useTranslationWithUtils.ts index 232ea998..7bd68db0 100644 --- a/src/hooks/useTranslationWithUtils.ts +++ b/src/hooks/useTranslationWithUtils.ts @@ -18,8 +18,6 @@ export const useTranslationWithUtils = ( } => { if (!namespaces || namespaces.length === 0) { namespaces = ['common']; - } else if (!namespaces.includes) { - namespaces.push; } const translation = useTranslation(namespaces); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index dcdfc169..72747ee4 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -4,9 +4,42 @@ * more details here: https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation */ export async function register() { - if ('nodejs' === process.env.NEXT_RUNTIME) { + if (process.env.NEXT_RUNTIME === 'nodejs') { console.log('Registering instrumentation'); const { validateAuthEnv } = await import('./server/auth'); validateAuthEnv(); } + + if (process.env.NEXT_RUNTIME !== 'nodejs') { + console.log('Skipping instrumentation on edge runtime'); + return; + } + + if (process.env.DATABASE_URL) { + const { db } = await import('./server/db'); + await db.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_cron;`; + } + + // Create cron jobs + if (process.env.CLEAR_BANK_CACHE_FREQUENCY && process.env.DATABASE_URL) { + const frequencies = ['weekly', 'monthly']; + + if (frequencies.includes(process.env.CLEAR_BANK_CACHE_FREQUENCY)) { + console.log('Setting up cron jobs...'); + + const { createRecurringDeleteBankCacheJob } = await import( + './server/api/services/scheduleService' + ); + console.log( + `Creating cron job for cleaning up bank cache ${process.env.CLEAR_BANK_CACHE_FREQUENCY}`, + ); + setTimeout( + () => + createRecurringDeleteBankCacheJob( + process.env.CLEAR_BANK_CACHE_FREQUENCY as 'weekly' | 'monthly', + ), + 1000 * 10, + ); + } + } } diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 9f53bb5d..9a92ed25 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -1,5 +1,6 @@ import { SiGithub, SiX } from '@icons-pack/react-simple-icons'; import { + CreditCard, Download, DownloadCloud, FileDown, @@ -24,15 +25,21 @@ import MainLayout from '~/components/Layout/MainLayout'; import { EntityAvatar } from '~/components/ui/avatar'; import { Button } from '~/components/ui/button'; import { env } from '~/env'; -import { type NextPageWithUser } from '~/types'; -import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; +import { BankConnection } from '~/components/Account/BankAccount/BankConnection'; import { bigIntReplacer } from '~/utils/numbers'; +import { + isBankConnectionConfigured, + whichBankConnectionConfigured, +} from '~/server/bankTransactionHelper'; +import { api } from '~/utils/api'; +import type { NextPageWithUser } from '~/types'; -const AccountPage: NextPageWithUser<{ feedBackPossible: boolean }> = ({ - user, - feedBackPossible, -}) => { +const AccountPage: NextPageWithUser<{ + feedBackPossible: boolean; + bankConnectionEnabled: boolean; + bankConnection: string; +}> = ({ user, feedBackPossible, bankConnectionEnabled, bankConnection }) => { const { t } = useTranslation(); const router = useRouter(); const userQuery = api.user.me.useQuery(); @@ -110,6 +117,17 @@ const AccountPage: NextPageWithUser<{ feedBackPossible: boolean }> = ({ + + + + {userQuery.data?.obapiProviderId ? t('actions.reconnect') : t('actions.connect')}{' '} + {t('bank_transactions.to_bank')} + + + {isCloud && ( @@ -173,6 +191,8 @@ AccountPage.auth = true; export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { feedbackPossible: !!env.FEEDBACK_EMAIL, + bankConnectionEnabled: !!isBankConnectionConfigured(), + bankConnection: whichBankConnectionConfigured(), ...(await customServerSideTranslations(context.locale, ['common'])), }, }); diff --git a/src/pages/add.tsx b/src/pages/add.tsx index 403f579a..c583f43c 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -12,11 +12,13 @@ import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { isBankConnectionConfigured } from '~/server/bankTransactionHelper'; const AddPage: NextPageWithUser<{ isStorageConfigured: boolean; enableSendingInvites: boolean; -}> = ({ user, isStorageConfigured, enableSendingInvites }) => { + bankConnectionEnabled: boolean; +}> = ({ user, isStorageConfigured, enableSendingInvites, bankConnectionEnabled }) => { const { t } = useTranslation('add_page'); const { setCurrentUser, @@ -42,6 +44,8 @@ const AddPage: NextPageWithUser<{ name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, + obapiProviderId: user.obapiProviderId ?? null, + bankingId: user.bankingId ?? null, }); }, [setCurrentUser, user]); @@ -136,6 +140,7 @@ const AddPage: NextPageWithUser<{ isStorageConfigured={isStorageConfigured} enableSendingInvites={enableSendingInvites} expenseId={_expenseId} + bankConnectionEnabled={!!bankConnectionEnabled} /> )} @@ -151,6 +156,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => ({ props: { isStorageConfigured: !!isStorageConfigured(), enableSendingInvites: !!env.ENABLE_SENDING_INVITES, + bankConnectionEnabled: !!isBankConnectionConfigured(), ...(await customServerSideTranslations(context.locale, ['common', 'categories', 'currencies'])), }, }); diff --git a/src/pages/balances.tsx b/src/pages/balances.tsx index c0d4ccbb..d8450a75 100644 --- a/src/pages/balances.tsx +++ b/src/pages/balances.tsx @@ -56,10 +56,8 @@ const BalancePage: NextPageWithUser = () => {
{balanceQuery.data?.youOwe.length ? (
- {/* */}
- {/* */}

{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(),