diff --git a/.env.example b/.env.example index bce3b4bc..a894f434 100644 --- a/.env.example +++ b/.env.example @@ -90,4 +90,10 @@ FEEDBACK_EMAIL= # Discord webhook for error notifications DISCORD_WEBHOOK_URL= + +# Currency rate provider, currently supported: 'frankfurter' (default), 'openexchangerates' and 'nbp'. See Readme for details. +CURRENCY_RATE_PROVIDER=frankfurter + +# Open Exchange Rates App ID +OPEN_EXCHANGE_RATES_APP_ID= #********* END OF OPTIONAL ENV VARS ********* diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 158ff247..250b8d85 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -2,7 +2,6 @@ name: Check on: - push - - pull_request jobs: check: @@ -28,15 +27,15 @@ jobs: - name: Install dependencies run: pnpm install - - name: Check code formatting + - name: Apply prettier formatting run: pnpm prettier --check . - - name: Run tsc - run: pnpm tsc --noEmit - - name: Run lint run: pnpm lint + - name: Run tsc + run: pnpm tsc --noEmit + - name: Run tests run: pnpm test diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 0595c4cd..d916607d 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -1,6 +1,6 @@ const config = { // Format all supported file types with prettier - '*.{js,jsx,ts,tsx,json,css,scss,md,yml,yaml,html}': ['prettier --write'], + '*': ['prettier --write -u'], // Run oxlint on JavaScript/TypeScript files '*.{js,jsx,ts,tsx}': ['oxlint --type-aware --fix'], diff --git a/README.md b/README.md index 9049f847..0e01689a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ It currently has most of the important features. - Download your data - Import from splitwise - simplify group debts -- **NEW** community translations, feel free to add your language! +- community translations, feel free to add your language! +- **UNRELEASED** currency conversion, quickly convert expenses and group balances **More features coming every day** @@ -64,6 +65,14 @@ All numbers are stored in the DB as `BigInt` data, with no floats what so ever, In case of an expense that cannot be split evenly, the leftover amounts are distributed randomly across participants. The assignment is as equal as possible, in the context of a single expense (similar to Splitwise). +#### Currency rate providers + +Currency rate APIs are usually paywalled or rate limited, except for banking institutions. We provide 3 providers, with a developer friendly interface for adding new ones, if you are in need of more capabilities. To save your rate limits, we cache each API call in the DB and try to get as much rates as possible in a single request. + +- [frankfurter](https://frankfurter.dev/) - completely free, but has a limited set of currencies. Check by fetching https://api.frankfurter.dev/v1/currencies +- [Open Exchange Rates](https://openexchangerates.org/) - very capable with a generous 1000 requests/day. Requires an account and an API key. While the free version only allows USD as the base currency, we simply join the rates together. Fetching ALL rates for a single day means one API call, so unless you want to do hundreds of searches in the past, you should be fine. +- [NBP](https://api.nbp.pl/en.html) - the central bank of Poland. Similiar case as OXR, but uses PLN as base currency and does not require an account/API key. The downside is that while table A has the most relevant (for Poland) currencies and is updated daily, table B with all the remaining ones is only published on Wednesdays. So if you need these currencies, the rates might be out of date. They also state that there is an API rate limit, but without a number, so it is to be reported. + ## Tech stack - [NextJS](https://nextjs.org/) diff --git a/next.config.js b/next.config.js index d3700bf7..3e04f6f4 100644 --- a/next.config.js +++ b/next.config.js @@ -1,16 +1,20 @@ import { PHASE_DEVELOPMENT_SERVER, PHASE_PRODUCTION_BUILD } from 'next/constants.js'; import i18nConfig from './next-i18next.config.js'; +import { fileURLToPath } from 'node:url'; +import { createJiti } from 'jiti'; +const jiti = createJiti(fileURLToPath(import.meta.url)); /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful * for Docker builds. */ -await import('./src/env.js'); +await jiti.import('./src/env'); /** @type {import("next").NextConfig} */ const nextConfig = { reactStrictMode: true, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, + transpilePackages: ['@t3-oss/env-nextjs', '@t3-oss/env-core'], /** * If you are using `appDir` then you must comment the below `i18n` config out. * diff --git a/package.json b/package.json index 34209a42..eada90e1 100644 --- a/package.json +++ b/package.json @@ -80,9 +80,10 @@ "husky": "^9.1.7", "jest": "30.0.5", "jest-environment-jsdom": "30.0.5", + "jiti": "^2.5.1", "lint-staged": "^16.1.5", - "oxlint": "^1.12.0", - "oxlint-tsgolint": "^0.0.4", + "oxlint": "^1.15.0", + "oxlint-tsgolint": "^0.2.0", "postcss": "^8.4.31", "prettier": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b26376a3..3cfc4d58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,15 +169,18 @@ importers: jest-environment-jsdom: specifier: 30.0.5 version: 30.0.5 + jiti: + specifier: ^2.5.1 + version: 2.5.1 lint-staged: specifier: ^16.1.5 version: 16.1.5 oxlint: - specifier: ^1.12.0 - version: 1.12.0 + specifier: ^1.15.0 + version: 1.15.0(oxlint-tsgolint@0.2.0) oxlint-tsgolint: - specifier: ^0.0.4 - version: 0.0.4 + specifier: ^0.2.0 + version: 0.2.0 postcss: specifier: ^8.4.31 version: 8.5.3 @@ -1465,6 +1468,9 @@ packages: '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1477,8 +1483,8 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.10': - resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} @@ -1489,12 +1495,18 @@ packages: '@jridgewell/sourcemap-codec@1.5.4': resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -1570,73 +1582,73 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxlint-tsgolint/darwin-arm64@0.0.4': - resolution: {integrity: sha512-qL0zqIYdYrXl6ghTIHnhJkvyYy1eKz0P8YIEp59MjY3/zNiyk/gtyp8LkwZdqb9ezbcX9UDQhSuSO1wURJsq8g==} + '@oxlint-tsgolint/darwin-arm64@0.2.0': + resolution: {integrity: sha512-ayJO9SmiJ15oV3+svIw8bqun0ySdjiD7L+ddwNB4vOAgUX/rdX1KTBnDb/ZEk6MOFBnFgbbiEiLRJLlGtuFYVQ==} cpu: [arm64] os: [darwin] - '@oxlint-tsgolint/darwin-x64@0.0.4': - resolution: {integrity: sha512-c3nSjqmDSKzemChAEUv/zy2e9cwgkkO/7rz4Y447+8pSbeZNHi3RrNpVHdrKL/Qep4pt6nFZE+6PoczZxHNQjg==} + '@oxlint-tsgolint/darwin-x64@0.2.0': + resolution: {integrity: sha512-iCrcjkqqyy3zq+yXOmNsjt/DiSU4u9yJ00hEr4oGSrrk7V0ju6eqrmsh8VGS74YLY3MCskTjeTyVDRR7huc3WQ==} cpu: [x64] os: [darwin] - '@oxlint-tsgolint/linux-arm64@0.0.4': - resolution: {integrity: sha512-P2BA54c/Ej5AGkChH1/7zMd6PwZfa+jnw8juB/JWops+BX+lbhbbBHz0cYduDBgWYjRo4e3OVJOTskqcpuMfNw==} + '@oxlint-tsgolint/linux-arm64@0.2.0': + resolution: {integrity: sha512-Kne4mrJGCZ0O+/ukcvWftCmDAEFUpMQ4q4wZdWVlnmNdTbtICIay3ofk/rzX0QoZmEZh0jy/G7p+5P0t9Bg5Sg==} cpu: [arm64] os: [linux] - '@oxlint-tsgolint/linux-x64@0.0.4': - resolution: {integrity: sha512-hbgLpnDNicPrbHOAQ9nNfLOSrUrdWANP/umR7P/cwCc1sv66eEs7bm4G3mrhRU8aXFBJmbhdNqiDSUkYYvHWJQ==} + '@oxlint-tsgolint/linux-x64@0.2.0': + resolution: {integrity: sha512-DKGFSR71fnExWpJXvN32SqWuEcT1XXeI1CKpO63jgXTAUpVl9H/BXG3+gNptSoZqzqeFTj8jOgiaX6VkOABqGA==} cpu: [x64] os: [linux] - '@oxlint-tsgolint/win32-arm64@0.0.4': - resolution: {integrity: sha512-ozKEppmwZhC5LMedClBEat6cXgBGUvxGOgsKK2ZZNE6zSScX7QbvJAOt3nWMGs8GQshHy/6ndMB33+uRloglQA==} + '@oxlint-tsgolint/win32-arm64@0.2.0': + resolution: {integrity: sha512-Grbkva1YH0eTRtv3MkVTFAycVwQSytcl8N52zNs1YresWwOlnNvNZ5EeLIaQaudcxwsbpZWR2Bdsfa467zDJTw==} cpu: [arm64] os: [win32] - '@oxlint-tsgolint/win32-x64@0.0.4': - resolution: {integrity: sha512-gLfx+qogW21QcaRKFg6ARgra7tSPqyn+Ems3FgTUyxV4OpJYn7KsQroygxOWElqv6JUobtvHBrxdB6YhlvERbQ==} + '@oxlint-tsgolint/win32-x64@0.2.0': + resolution: {integrity: sha512-asfPqgu7r1H8NmBNxfMpZER6WvrUTH8BDMPIcChBhrUjuSmt4UMyiyul3CEPZLPQcPlaWCeGnConTu3BabK4Fw==} cpu: [x64] os: [win32] - '@oxlint/darwin-arm64@1.12.0': - resolution: {integrity: sha512-Pv+Ho1uq2ny8g2P6JgQpaIUF1FHPL32DfOlZhKqmzDT3PydtFvZp/7zNyJE3BIXeTOOOG1Eg12hjZHMLsWxyNw==} + '@oxlint/darwin-arm64@1.15.0': + resolution: {integrity: sha512-fwYg7WDKI6eAErREBGMXkIAOqBuBFN0LWbQJvVNXCGjywGxsisdwkHnNu4UG8IpHo4P71mUxf3l2xm+5Xiy+TA==} cpu: [arm64] os: [darwin] - '@oxlint/darwin-x64@1.12.0': - resolution: {integrity: sha512-kNXPH/7jXjX4pawrEWXQHOasOdOsrYKhskA1qYwLYcv/COVSoxOSElkQtQa+KxN5zzt3F02kBdWDndLpgJLbLQ==} + '@oxlint/darwin-x64@1.15.0': + resolution: {integrity: sha512-RtaAmB6NZZx4hvjCg6w35shzRY5fLclbMsToC92MTZ9lMDF9LotzcbyNHCZ1tvZb1tNPObpIsuX16BFeElF8nw==} cpu: [x64] os: [darwin] - '@oxlint/linux-arm64-gnu@1.12.0': - resolution: {integrity: sha512-U7NETs02K55ZyDlgdhx4lWeFYbkUKcL+YcG+Ak70EyEt/BKIIVt4B84VdV1JzC71FErUipDYAwPJmxMREXr4Sg==} + '@oxlint/linux-arm64-gnu@1.15.0': + resolution: {integrity: sha512-8uV0lAbmqp93KTBlJWyCdQWuxTzLn+QrDRidUaCLJjn65uvv8KlRhZJoZoyLh17X6U/cgezYktWTMiMhxX56BA==} cpu: [arm64] os: [linux] - '@oxlint/linux-arm64-musl@1.12.0': - resolution: {integrity: sha512-e4Pb2eZu3V2BsiX4t4gyv9iJ8+KRT6bkoWM5uC9BLX7edsVchwLwL6LB2vPYusYdPPrxdjlFCg6ni+9wlw7FbQ==} + '@oxlint/linux-arm64-musl@1.15.0': + resolution: {integrity: sha512-/+hTqh1J29+2GitKrWUHIYjQBM1szWSJ1U7OzQlgL+Uvf8jxg4sn1nV79LcPMXhC2t8lZy5EOXOgwIh92DsdhQ==} cpu: [arm64] os: [linux] - '@oxlint/linux-x64-gnu@1.12.0': - resolution: {integrity: sha512-qJK98Dj/z7Nbm0xoz0nCCMFGy0W/kLewPzOK5QENxuUoQQ6ymt7/75rXOuTwAZJ6JFTarqfSuMAA0pka6Tmytw==} + '@oxlint/linux-x64-gnu@1.15.0': + resolution: {integrity: sha512-GzeY3AhUd49yV+/76Gw0pjpwUJwxCkwYAJTNe7fFTdWjEQ6M6g8ZzJg5FKtUvgA5sMgmfzHhvSXxvT57YhcXnA==} cpu: [x64] os: [linux] - '@oxlint/linux-x64-musl@1.12.0': - resolution: {integrity: sha512-jNeltpHc1eonSev/bWKipJ7FI6+Rc7EXh6Y7E0pm8e95sc1klFA29FFVs3FjMA6CCa+SRT0u0nnNTTAtf2QOiQ==} + '@oxlint/linux-x64-musl@1.15.0': + resolution: {integrity: sha512-p/7+juizUOCpGYreFmdfmIOSSSE3+JfsgnXnOHuP8mqlZfiOeXyevyajuXpPNRM60+k0reGvlV7ezp1iFitF7w==} cpu: [x64] os: [linux] - '@oxlint/win32-arm64@1.12.0': - resolution: {integrity: sha512-T3fpNZJ3Q9YGgJTKc1YyvGoomSXnrV5mREz0QACE06zUzfS8EWyaYc/GN17FhHvQ4uQk/1xLgnM6FPsuLMeRhw==} + '@oxlint/win32-arm64@1.15.0': + resolution: {integrity: sha512-2LaDLOtCMq+lzIQ63Eir3UJV/hQNlw01xtsij2L8sSxt4gA+zWvubOQJQIOPGMDxEKFcWT1lo/6YEXX/sNnZDA==} cpu: [arm64] os: [win32] - '@oxlint/win32-x64@1.12.0': - resolution: {integrity: sha512-2eC4XQ1SMM2z7bCDG+Ifrn5GrvP6fkL0FGi4ZwDCrx6fwb1byFrXgSUNIPiqiiqBBrFRMKlXzU9zD6IjuFlUOg==} + '@oxlint/win32-x64@1.15.0': + resolution: {integrity: sha512-+jgRPpZrFIcrNxCVsDIy6HVCRpKVDN0DHD8VJodjrsDv6heqhq/qCTa2IXY3R4glWe1nWQ5JgdFKLn3Bl+aLNg==} cpu: [x64] os: [win32] @@ -2877,6 +2889,9 @@ packages: '@types/node@22.16.5': resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} + '@types/node@22.18.4': + resolution: {integrity: sha512-UJdblFqXymSBhmZf96BnbisoFIr8ooiiBRMolQgg77Ea+VM37jXw76C2LQr9n8wm9+i/OvlUlW6xSvqwzwqznw==} + '@types/nodemailer@6.4.17': resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} @@ -3204,6 +3219,10 @@ packages: resolution: {integrity: sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==} engines: {node: '>= 16'} + baseline-browser-mapping@2.8.4: + resolution: {integrity: sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==} + hasBin: true + bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} @@ -3226,6 +3245,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.26.0: + resolution: {integrity: sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -3266,6 +3290,9 @@ packages: caniuse-lite@1.0.30001718: resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3492,6 +3519,9 @@ packages: electron-to-chromium@1.5.157: resolution: {integrity: sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==} + electron-to-chromium@1.5.218: + resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==} + emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} @@ -3509,8 +3539,8 @@ packages: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.18.2: - resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} entities@6.0.0: @@ -4204,6 +4234,10 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jiti@2.5.1: + resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} + hasBin: true + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -4551,6 +4585,9 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.21: + resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + nodemailer@6.10.1: resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} engines: {node: '>=6.0.0'} @@ -4607,14 +4644,19 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} - oxlint-tsgolint@0.0.4: - resolution: {integrity: sha512-KFWVP+VU3ymgK/Dtuf6iRkqjo+aN42lS1YThY6JWlNi1GQqm7wtio/kAwssqDhm8kP+CVXbgZAtu1wgsK4XeTg==} + oxlint-tsgolint@0.2.0: + resolution: {integrity: sha512-37Hy+FT1sz8hHUo31JIgFDA8NcFndexrg5okutWRPXNejJwB9hKN+pyInaQQIv4XDsgNcQsSR2VJoq99eaGk9g==} hasBin: true - oxlint@1.12.0: - resolution: {integrity: sha512-tBQ9aB00aYLlGXE21WJHnKQAI8xoi2V6Eiz/WvGV7FwU9YLYuNOurEEVbfoS5u0ODX8GLvGWj1fdHh5Rb74Kkw==} + oxlint@1.15.0: + resolution: {integrity: sha512-GZngkdF2FabM0pp0/l5OOhIQg+9L6LmOrmS8V8Vg+Swv9/VLJd/oc/LtAkv4HO45BNWL3EVaXzswI0CmGokVzw==} engines: {node: '>=8.*'} hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.2.0' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} @@ -5227,6 +5269,10 @@ packages: resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} engines: {node: '>=6'} + tapable@2.2.3: + resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} + engines: {node: '>=6'} + tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} @@ -5260,8 +5306,8 @@ packages: engines: {node: '>=10'} hasBin: true - terser@5.43.1: - resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} engines: {node: '>=10'} hasBin: true @@ -7449,6 +7495,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -7459,10 +7510,10 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.10': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/source-map@0.3.6': dependencies: @@ -7473,6 +7524,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7483,6 +7536,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7538,46 +7596,46 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@oxlint-tsgolint/darwin-arm64@0.0.4': + '@oxlint-tsgolint/darwin-arm64@0.2.0': optional: true - '@oxlint-tsgolint/darwin-x64@0.0.4': + '@oxlint-tsgolint/darwin-x64@0.2.0': optional: true - '@oxlint-tsgolint/linux-arm64@0.0.4': + '@oxlint-tsgolint/linux-arm64@0.2.0': optional: true - '@oxlint-tsgolint/linux-x64@0.0.4': + '@oxlint-tsgolint/linux-x64@0.2.0': optional: true - '@oxlint-tsgolint/win32-arm64@0.0.4': + '@oxlint-tsgolint/win32-arm64@0.2.0': optional: true - '@oxlint-tsgolint/win32-x64@0.0.4': + '@oxlint-tsgolint/win32-x64@0.2.0': optional: true - '@oxlint/darwin-arm64@1.12.0': + '@oxlint/darwin-arm64@1.15.0': optional: true - '@oxlint/darwin-x64@1.12.0': + '@oxlint/darwin-x64@1.15.0': optional: true - '@oxlint/linux-arm64-gnu@1.12.0': + '@oxlint/linux-arm64-gnu@1.15.0': optional: true - '@oxlint/linux-arm64-musl@1.12.0': + '@oxlint/linux-arm64-musl@1.15.0': optional: true - '@oxlint/linux-x64-gnu@1.12.0': + '@oxlint/linux-x64-gnu@1.15.0': optional: true - '@oxlint/linux-x64-musl@1.12.0': + '@oxlint/linux-x64-musl@1.15.0': optional: true - '@oxlint/win32-arm64@1.12.0': + '@oxlint/win32-arm64@1.15.0': optional: true - '@oxlint/win32-x64@1.12.0': + '@oxlint/win32-x64@1.15.0': optional: true '@panva/hkdf@1.2.1': {} @@ -8788,7 +8846,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 enhanced-resolve: 5.18.1 - jiti: 2.4.2 + jiti: 2.5.1 lightningcss: 1.30.1 magic-string: 0.30.17 source-map-js: 1.2.1 @@ -8983,6 +9041,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.18.4': + dependencies: + undici-types: 6.21.0 + '@types/nodemailer@6.4.17': dependencies: '@types/node': 18.19.103 @@ -9327,6 +9389,8 @@ snapshots: balanced-match@3.0.1: {} + baseline-browser-mapping@2.8.4: {} + bn.js@4.12.2: {} boring-avatars@1.11.2: {} @@ -9348,6 +9412,14 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.5) + browserslist@4.26.0: + dependencies: + baseline-browser-mapping: 2.8.4 + caniuse-lite: 1.0.30001741 + electron-to-chromium: 1.5.218 + node-releases: 2.0.21 + update-browserslist-db: 1.1.3(browserslist@4.26.0) + bser@2.1.1: dependencies: node-int64: 0.4.0 @@ -9385,6 +9457,8 @@ snapshots: caniuse-lite@1.0.30001718: {} + caniuse-lite@1.0.30001741: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -9585,6 +9659,8 @@ snapshots: electron-to-chromium@1.5.157: {} + electron-to-chromium@1.5.218: {} + emittery@0.13.1: {} emoji-regex@10.4.0: {} @@ -9598,10 +9674,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.2 - enhanced-resolve@5.18.2: + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.2 + tapable: 2.2.3 entities@6.0.0: {} @@ -10586,7 +10662,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.16.5 + '@types/node': 22.18.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -10613,6 +10689,8 @@ snapshots: jiti@2.4.2: {} + jiti@2.5.1: {} + jose@4.15.9: {} js-tokens@4.0.0: {} @@ -10934,6 +11012,8 @@ snapshots: node-releases@2.0.19: {} + node-releases@2.0.21: {} + nodemailer@6.10.1: {} normalize-path@3.0.0: {} @@ -10988,26 +11068,26 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxlint-tsgolint@0.0.4: + oxlint-tsgolint@0.2.0: optionalDependencies: - '@oxlint-tsgolint/darwin-arm64': 0.0.4 - '@oxlint-tsgolint/darwin-x64': 0.0.4 - '@oxlint-tsgolint/linux-arm64': 0.0.4 - '@oxlint-tsgolint/linux-x64': 0.0.4 - '@oxlint-tsgolint/win32-arm64': 0.0.4 - '@oxlint-tsgolint/win32-x64': 0.0.4 - - oxlint@1.12.0: + '@oxlint-tsgolint/darwin-arm64': 0.2.0 + '@oxlint-tsgolint/darwin-x64': 0.2.0 + '@oxlint-tsgolint/linux-arm64': 0.2.0 + '@oxlint-tsgolint/linux-x64': 0.2.0 + '@oxlint-tsgolint/win32-arm64': 0.2.0 + '@oxlint-tsgolint/win32-x64': 0.2.0 + + oxlint@1.15.0(oxlint-tsgolint@0.2.0): optionalDependencies: - '@oxlint/darwin-arm64': 1.12.0 - '@oxlint/darwin-x64': 1.12.0 - '@oxlint/linux-arm64-gnu': 1.12.0 - '@oxlint/linux-arm64-musl': 1.12.0 - '@oxlint/linux-x64-gnu': 1.12.0 - '@oxlint/linux-x64-musl': 1.12.0 - '@oxlint/win32-arm64': 1.12.0 - '@oxlint/win32-x64': 1.12.0 - oxlint-tsgolint: 0.0.4 + '@oxlint/darwin-arm64': 1.15.0 + '@oxlint/darwin-x64': 1.15.0 + '@oxlint/linux-arm64-gnu': 1.15.0 + '@oxlint/linux-arm64-musl': 1.15.0 + '@oxlint/linux-x64-gnu': 1.15.0 + '@oxlint/linux-x64-musl': 1.15.0 + '@oxlint/win32-arm64': 1.15.0 + '@oxlint/win32-x64': 1.15.0 + oxlint-tsgolint: 0.2.0 p-limit@2.3.0: dependencies: @@ -11633,6 +11713,8 @@ snapshots: tapable@2.2.2: {} + tapable@2.2.3: {} + tar@7.4.3: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -11653,11 +11735,11 @@ snapshots: terser-webpack-plugin@5.3.14(webpack@5.99.9): dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 - terser: 5.43.1 + terser: 5.44.0 webpack: 5.99.9 terser@5.39.2: @@ -11667,9 +11749,9 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - terser@5.43.1: + terser@5.44.0: dependencies: - '@jridgewell/source-map': 0.3.10 + '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -11834,6 +11916,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.1.3(browserslist@4.26.0): + dependencies: + browserslist: 4.26.0 + escalade: 3.2.0 + picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@19.1.8)(react@19.1.0): dependencies: react: 19.1.0 @@ -11921,9 +12009,9 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 - browserslist: 4.24.5 + browserslist: 4.26.0 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.2 + enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -11934,7 +12022,7 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 4.3.2 - tapable: 2.2.2 + tapable: 2.2.3 terser-webpack-plugin: 5.3.14(webpack@5.99.9) watchpack: 2.4.4 webpack-sources: 3.3.3 diff --git a/prisma/migrations/20250905152203_currency_conversion/migration.sql b/prisma/migrations/20250905152203_currency_conversion/migration.sql new file mode 100644 index 00000000..52e31aa6 --- /dev/null +++ b/prisma/migrations/20250905152203_currency_conversion/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - A unique constraint covering the columns `[otherConversion]` on the table `Expense` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterEnum +ALTER TYPE "SplitType" ADD VALUE 'CURRENCY_CONVERSION'; + +-- AlterTable +ALTER TABLE "Expense" ADD COLUMN "otherConversion" TEXT; + +-- CreateTable +CREATE UNLOGGED TABLE "CurrencyRateCache" ( + "from" TEXT NOT NULL, + "to" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "rate" DOUBLE PRECISION NOT NULL, + "insertedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CurrencyRateCache_pkey" PRIMARY KEY ("from","to","date") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Expense_otherConversion_key" ON "Expense"("otherConversion"); + +-- AddForeignKey +ALTER TABLE "Expense" ADD CONSTRAINT "Expense_otherConversion_fkey" FOREIGN KEY ("otherConversion") REFERENCES "Expense"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 438e34e5..7908ec0c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,32 +135,36 @@ enum SplitType { SHARE ADJUSTMENT SETTLEMENT + CURRENCY_CONVERSION } model Expense { - id String @id @default(cuid()) - paidBy Int - addedBy Int - name String - category String - amount BigInt - splitType SplitType @default(EQUAL) - expenseDate DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - currency String - fileKey String? - groupId Int? - deletedAt DateTime? - deletedBy Int? - updatedBy Int? - group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) - paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) - addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) - deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) - updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) - expenseParticipants ExpenseParticipant[] - expenseNotes ExpenseNote[] + id String @id @default(cuid()) + paidBy Int + addedBy Int + name String + category String + amount BigInt + splitType SplitType @default(EQUAL) + expenseDate DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + currency String + fileKey String? + groupId Int? + deletedAt DateTime? + deletedBy Int? + updatedBy Int? + otherConversion String? @unique + group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade) + paidByUser User @relation(name: "PaidByUser", fields: [paidBy], references: [id], onDelete: Cascade) + addedByUser User @relation(name: "AddedByUser", fields: [addedBy], references: [id], onDelete: Cascade) + deletedByUser User? @relation(name: "DeletedByUser", fields: [deletedBy], references: [id], onDelete: Cascade) + updatedByUser User? @relation(name: "UpdatedByUser", fields: [updatedBy], references: [id], onDelete: SetNull) + conversionTo Expense? @relation(name: "CurrencyConversion", fields: [otherConversion], references: [id], onDelete: Cascade) + conversionFrom Expense? @relation(name: "CurrencyConversion") + expenseParticipants ExpenseParticipant[] + expenseNotes ExpenseNote[] @@index([groupId]) @@index([paidBy]) @@ -186,6 +190,16 @@ model ExpenseNote { expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) } +model CurrencyRateCache { + from String + to String + date DateTime + rate Float + insertedAt DateTime @default(now()) + + @@id([from, to, date]) +} + model PushNotification { userId Int @id subscription String diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 9f012ac6..acc763b1 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -36,6 +36,7 @@ "invite": "Invite", "export": "Export", "import": "Import", + "fetch": "Fetch", "add_expense": "Add expense", "edit_expense": "Edit expense", "settle_up": "Settle up" @@ -83,12 +84,23 @@ "expense_details": "Expense details", "select_currency": "Select currency", "outstanding_balances": "Outstanding balances", - "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise" + "share_text": "Check out SplitPro. It's an open source free alternative for Splitwise", + "currency_conversion": { + "title": "Currency conversion", + "description": "Convert the amount to a different currency.", + "amount_to_receive": "Amount to receive", + "exchange_rate": "Exchange rate", + "fetching_rate": "Fetching rate...", + "rate": "Rate", + "success_toast": "Currency conversion set successfully", + "error_toast": "Error while setting currency conversion" + } }, "errors": { "name_required": "Name is required", "saving_expense": "Error while saving expense", "something_went_wrong": "Something went wrong", - "valid_email": "Enter valid email" + "valid_email": "Enter valid email", + "invalid_currency_code": "Invalid currency code {{code}}" } } diff --git a/public/locales/en/home.json b/public/locales/en/home.json index 7a9c98a4..6c5c971b 100644 --- a/public/locales/en/home.json +++ b/public/locales/en/home.json @@ -58,6 +58,10 @@ "i18": { "title": "Internationalization", "description": "Available in multiple languages to make expense splitting accessible worldwide" + }, + "currency_conversion": { + "title": "Currency Conversion", + "description": "Quickly convert between different currencies using up-to-date or historical exchange rates" } }, "footer": { diff --git a/public/locales/pl/common.json b/public/locales/pl/common.json index 5ee4ef54..6fa77976 100644 --- a/public/locales/pl/common.json +++ b/public/locales/pl/common.json @@ -1,94 +1,94 @@ { - "meta": { - "title": "SplitPro: Dziel wydatki ze znajomymi za darmo", - "description": "Dziel wydatki ze znajomymi - za darmo", - "application_name": "SplitPro" + "meta": { + "title": "SplitPro: Dziel wydatki ze znajomymi za darmo", + "description": "Dziel wydatki ze znajomymi - za darmo", + "application_name": "SplitPro" + }, + "navigation": { + "app_name": "SplitPro", + "balances": "Salda", + "groups": "Grupy", + "add_expense": "Dodaj Wydatek", + "add": "Dodaj", + "activity": "Aktywność", + "account": "Konto" + }, + "ui": { + "actors": { + "you": "Ty", + "you_dativus": "Ciebie", + "you_accusativus": "Tobie", + "friends": "Znajomi", + "groups": "Grupy", + "members": "Członkowie", + "owner": "Właściciel", + "all": "Wszyscy" }, - "navigation": { - "app_name": "SplitPro", - "balances": "Salda", - "groups": "Grupy", - "add_expense": "Dodaj Wydatek", - "add": "Dodaj", - "activity": "Aktywność", - "account": "Konto" + "actions": { + "cancel": "Anuluj", + "confirm": "Potwierdź", + "submit": "Zapisz", + "close": "Zamknij", + "back": "Wstecz", + "create": "Utwórz", + "save": "Zapisz", + "leave": "Opuść", + "invite": "Zaproś", + "export": "Eksportuj", + "import": "Importuj", + "add_expense": "Dodaj wydatek", + "edit_expense": "Edytuj wydatek", + "settle_up": "Rozlicz" }, - "ui": { - "actors": { - "you": "Ty", - "you_dativus": "Ciebie", - "you_accusativus": "Tobie", - "friends": "Znajomi", - "groups": "Grupy", - "members": "Członkowie", - "owner": "Właściciel", - "all": "Wszyscy" - }, - "actions": { - "cancel": "Anuluj", - "confirm": "Potwierdź", - "submit": "Zapisz", - "close": "Zamknij", - "back": "Wstecz", - "create": "Utwórz", - "save": "Zapisz", - "leave": "Opuść", - "invite": "Zaproś", - "export": "Eksportuj", - "import": "Importuj", - "add_expense": "Dodaj wydatek", - "edit_expense": "Edytuj wydatek", - "settle_up": "Rozlicz" - }, - "expense": { - "you": { - "lent": "odzyskujesz", - "owe": "masz do oddania", - "paid": "płacisz", - "pay": "płacisz", - "get": "dostajesz", - "received": "dostajesz", - "deleted": "usuwasz" - }, - "user": { - "lent": "odzyskuje", - "owe": "ma do oddania", - "paid": "płaci", - "pay": "płaci", - "get": "dostaje", - "received": "dostaje", - "deleted": "usuwa" - }, - "for": "za", - "paid_by": "Zapłacone przez", - "received_by": "Otrzymane przez", - "to": "do", - "from": "od" - }, - "balance": "saldo", - "balances": "salda", - "or": "lub", - "and": "i", - "today": "Dzisiaj", - "delete": "Usuń", - "edited_by": "Edytowane przez", - "deleted_by": "Usunięte przez", - "added_by": "Dodane przez", - "on": "dnia", - "not_involved": "Nie uczestniczy", - "no_activity": "Brak aktywności", - "settled_up": "Rozliczone", - "settle_up_name": "Rozlicz", - "settlement": "Rozliczenie", - "expense_details": "Szczegóły wydatku", - "select_currency": "Wybierz walutę", - "outstanding_balances": "Zaległe salda", - "share_text": "Sprawdź SplitPro. To darmowa alternatywa open source dla Splitwise" + "expense": { + "you": { + "lent": "odzyskujesz", + "owe": "masz do oddania", + "paid": "płacisz", + "pay": "płacisz", + "get": "dostajesz", + "received": "dostajesz", + "deleted": "usuwasz" + }, + "user": { + "lent": "odzyskuje", + "owe": "ma do oddania", + "paid": "płaci", + "pay": "płaci", + "get": "dostaje", + "received": "dostaje", + "deleted": "usuwa" + }, + "for": "za", + "paid_by": "Zapłacone przez", + "received_by": "Otrzymane przez", + "to": "do", + "from": "od" }, - "errors": { - "name_required": "Nazwa jest wymagana", - "saving_expense": "Błąd podczas zapisywania wydatku", - "something_went_wrong": "Coś poszło nie tak", - "valid_email": "Wprowadź prawidłowy email" - } + "balance": "saldo", + "balances": "salda", + "or": "lub", + "and": "i", + "today": "Dzisiaj", + "delete": "Usuń", + "edited_by": "Edytowane przez", + "deleted_by": "Usunięte przez", + "added_by": "Dodane przez", + "on": "dnia", + "not_involved": "Nie uczestniczy", + "no_activity": "Brak aktywności", + "settled_up": "Rozliczone", + "settle_up_name": "Rozlicz", + "settlement": "Rozliczenie", + "expense_details": "Szczegóły wydatku", + "select_currency": "Wybierz walutę", + "outstanding_balances": "Zaległe salda", + "share_text": "Sprawdź SplitPro. To darmowa alternatywa open source dla Splitwise" + }, + "errors": { + "name_required": "Nazwa jest wymagana", + "saving_expense": "Błąd podczas zapisywania wydatku", + "something_went_wrong": "Coś poszło nie tak", + "valid_email": "Wprowadź prawidłowy email" + } } diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 8dab0a5f..371e3a61 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -1,34 +1,34 @@ -import { CalendarIcon, HeartHandshakeIcon } from 'lucide-react'; +import { HeartHandshakeIcon } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import React, { useCallback } from 'react'; -import { useTranslation } from 'next-i18next'; +import React, { useCallback, useMemo } from 'react'; import { type CurrencyCode } from '~/lib/currency'; -import { cn } from '~/lib/utils'; import { useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; -import { toSafeBigInt } from '~/utils/numbers'; +import { currencyConversion, toSafeBigInt, toUIString } from '~/utils/numbers'; +import { toast } from 'sonner'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { Button } from '../ui/button'; -import { Calendar } from '../ui/calendar'; import { Input } from '../ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { CategoryPicker } from './CategoryPicker'; import { CurrencyPicker } from './CurrencyPicker'; +import { DateSelector } from './DateSelector'; import { SelectUserOrGroup } from './SelectUserOrGroup'; import { SplitTypeSection } from './SplitTypeSection'; import { UploadFile } from './UploadFile'; import { UserInput } from './UserInput'; -import { toast } from 'sonner'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +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 }) => { - const { t, toUIDate } = useTranslationWithUtils(['expense_details']); + const { t } = useTranslationWithUtils(['expense_details']); const showFriends = useAddExpenseStore((s) => s.showFriends); const amount = useAddExpenseStore((s) => s.amount); const isNegative = useAddExpenseStore((s) => s.isNegative); @@ -60,12 +60,13 @@ export const AddOrEditExpensePage: React.FC<{ const updateProfile = api.user.updateUserDetail.useMutation(); const onCurrencyPick = useCallback( - (currency: CurrencyCode) => { - updateProfile.mutate({ currency }); + (newCurrency: CurrencyCode) => { + updateProfile.mutate({ currency: newCurrency }); - setCurrency(currency); + previousCurrencyRef.current = currency; + setCurrency(newCurrency); }, - [setCurrency, updateProfile], + [currency, setCurrency, updateProfile], ); const router = useRouter(); @@ -160,10 +161,46 @@ export const AddOrEditExpensePage: React.FC<{ (e: React.ChangeEvent) => { const { value } = e.target; onUpdateAmount(value); + previousCurrencyRef.current = null; + }, + [onUpdateAmount], + ); + + const previousCurrencyRef = React.useRef(null); + + const onConvertAmount: React.ComponentProps['onSubmit'] = useCallback( + ({ amount: absAmount, rate }) => { + const targetAmount = (absAmount >= 0n ? 1n : -1n) * currencyConversion(absAmount, rate); + onUpdateAmount(toUIString(targetAmount)); + previousCurrencyRef.current = null; }, [onUpdateAmount], ); + const currencyConversionComponent = useMemo(() => { + if ( + currency === previousCurrencyRef.current || + previousCurrencyRef.current === null || + !amount || + 0n === amount + ) { + return null; + } + + return ( + + + + ); + }, [amount, currency, onConvertAmount]); + return (
@@ -210,6 +247,7 @@ export const AddOrEditExpensePage: React.FC<{ inputMode="decimal" value={amtStr} onChange={onAmountChange} + rightIcon={currencyConversionComponent} />
@@ -218,34 +256,12 @@ export const AddOrEditExpensePage: React.FC<{
-
- - - - - - - - -
+
{isStorageConfigured ? : null}
+ ), [currentCurrency], ); @@ -51,12 +65,13 @@ function CurrencyPickerInner({ return ( = (calendarProps) => { + const { t, toUIDate } = useTranslationWithUtils(['expense_details']); + + return ( +
+ + + + + + + + +
+ ); +}; diff --git a/src/components/Expense/BalanceList.tsx b/src/components/Expense/BalanceList.tsx index 7c6b5d9f..932bb914 100644 --- a/src/components/Expense/BalanceList.tsx +++ b/src/components/Expense/BalanceList.tsx @@ -1,14 +1,16 @@ import type { GroupBalance, User } from '@prisma/client'; import { clsx } from 'clsx'; -import { Info } from 'lucide-react'; -import { Fragment, useMemo } from 'react'; +import { type ComponentProps, Fragment, useCallback, useMemo } from 'react'; import { EntityAvatar } from '~/components/ui/avatar'; import { api } from '~/utils/api'; import { BigMath, toUIString } from '~/utils/numbers'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { GroupSettleUp } from '../Friend/GroupSettleup'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { Button } from '../ui/button'; +import { CURRENCY_CONVERSION_ICON, SETTLEUP_ICON } from '../ui/categoryIcons'; interface UserWithBalance { user: User; @@ -23,6 +25,9 @@ export const BalanceList: React.FC<{ const { displayName, t } = useTranslationWithUtils(['expense_details']); const userQuery = api.user.me.useQuery(); + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const apiUtils = api.useUtils(); + const userMap = useMemo(() => { const res = users.reduce>((acc, user) => { acc[user.id] = { user, balances: {}, total: {} }; @@ -45,74 +50,87 @@ export const BalanceList: React.FC<{ }, [groupBalances, users]); return ( - <> -
- - {t('ui.balance_list.press_balance_info')} -
- - {Object.values(userMap).map(({ user, total, balances }) => { - let totalAmount: [string, bigint] = ['', 0n]; - const isCurrentUser = userQuery.data?.id === user.id; + + {Object.values(userMap).map(({ user, total, balances }) => { + let totalAmount: [string, bigint] = ['', 0n]; + const isCurrentUser = userQuery.data?.id === user.id; - Object.entries(total).forEach(([currency, amount]) => { - if (BigMath.abs(amount) > BigMath.abs(totalAmount[1])) { - totalAmount = [currency, amount]; - } - }); + Object.entries(total).forEach(([currency, amount]) => { + if (BigMath.abs(amount) > BigMath.abs(totalAmount[1])) { + totalAmount = [currency, amount]; + } + }); - return ( - - -
- -
- {displayName(user, userQuery.data?.id)} - {Object.values(total).every((amount) => 0n === amount) ? ( + return ( + + +
+ +
+ {displayName(user, userQuery.data?.id)} + {Object.values(total).every((amount) => 0n === amount) ? ( + + {' '} + {isCurrentUser + ? t('ui.balance_list.are_settled_up') + : t('ui.balance_list.is_settled_up')} + + ) : ( + <> {' '} - {isCurrentUser - ? t('ui.balance_list.are_settled_up') - : t('ui.balance_list.is_settled_up')} + {t( + `ui.expense.${isCurrentUser ? 'you' : 'user'}.${0 < totalAmount[1] ? 'lent' : 'owe'}`, + { ns: 'common' }, + )}{' '} - ) : ( - <> - - {' '} - {t( - `ui.expense.${isCurrentUser ? 'you' : 'user'}.${0 < totalAmount[1] ? 'lent' : 'owe'}`, - { ns: 'common' }, - )}{' '} - - - {toUIString(totalAmount[1])} {totalAmount[0]} - - - )} -
+ + {toUIString(totalAmount[1])} {totalAmount[0]} + + + )}
-
- - {Object.entries(balances).map(([friendId, perFriendBalances]) => { - const friend = userMap[+friendId]!.user; +
+ + + {Object.entries(balances).map(([friendId, perFriendBalances]) => { + const friend = userMap[+friendId]!.user; - return ( - - {Object.entries(perFriendBalances).map(([currency, amount]) => ( - + {Object.entries(perFriendBalances).map(([currency, amount]) => { + if (0n === amount) { + return null; + } + + const sender = 0 < amount ? friend : user; + const receiver = 0 < amount ? user : friend; + + const onSubmit: ComponentProps['onSubmit'] = + useCallback( + async (data) => { + await addOrEditCurrencyConversionMutation.mutateAsync({ + ...data, + senderId: sender.id, + receiverId: receiver.id, + groupId: groupBalances[0]!.groupId, + }); + await apiUtils.invalidate(); + }, + [sender.id, receiver.id], + ); + + return ( +
-
+
{displayName(friend, userQuery.data?.id)} @@ -131,27 +149,49 @@ export const BalanceList: React.FC<{ > {toUIString(amount)} {currency} - + {' '} {t(`ui.expense.${0 < amount ? 'to' : 'from'}`, { ns: 'common', })}{' '} - + {displayName(user, userQuery.data?.id, 'accusativus')}
- - ))} - - ); - })} - - - ); - })} - - +
+ + + + + + +
+
+ ); + })} + + ); + })} + + + ); + })} + ); }; diff --git a/src/components/Expense/ExpenseDetails.tsx b/src/components/Expense/ExpenseDetails.tsx index 367fac90..67529cf4 100644 --- a/src/components/Expense/ExpenseDetails.tsx +++ b/src/components/Expense/ExpenseDetails.tsx @@ -1,30 +1,34 @@ -import { type Expense, type ExpenseParticipant, type User } from '@prisma/client'; import { isSameDay } from 'date-fns'; import { type User as NextUser } from 'next-auth'; import { toUIString } from '~/utils/numbers'; +import type { inferRouterOutputs } from '@trpc/server'; +import { PencilIcon } from 'lucide-react'; +import React, { type ComponentProps, useCallback } from 'react'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { isCurrencyCode } from '~/lib/currency'; +import type { ExpenseRouter } from '~/server/api/routers/expense'; +import { useAddExpenseStore } from '~/store/addStore'; +import { api } from '~/utils/api'; +import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { EntityAvatar } from '../ui/avatar'; +import { Button } from '../ui/button'; +import { CategoryIcon } from '../ui/categoryIcons'; import { Separator } from '../ui/separator'; import { Receipt } from './Receipt'; -import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; -import type { FC } from 'react'; -import { CategoryIcon } from '../ui/categoryIcons'; + +type ExpenseDetailsOutput = NonNullable['getExpenseDetails']>; interface ExpenseDetailsProps { user: NextUser; - expense: Expense & { - expenseParticipants: (ExpenseParticipant & { user: User })[]; - addedByUser: User; - paidByUser: User; - deletedByUser: User | null; - updatedByUser: User | null; - }; + expense: ExpenseDetailsOutput; storagePublicUrl?: string; } -const ExpenseDetails: FC = ({ user, expense, storagePublicUrl }) => { +const ExpenseDetails: React.FC = ({ user, expense, storagePublicUrl }) => { const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + return ( <>
@@ -73,16 +77,6 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU
- {/* */}

{displayName(expense.paidByUser, user.id)}{' '} @@ -95,33 +89,109 @@ const ExpenseDetails: FC = ({ user, expense, storagePublicU

{expense.expenseParticipants - .filter( - (partecipant) => - (expense.paidBy === partecipant.userId ? (expense.amount ?? 0n) : 0n) !== - partecipant.amount, - ) - .map((partecipant) => ( -
- -

- {displayName(partecipant.user, user.id)}{' '} - {t( - `ui.expense.${user.id === partecipant.userId ? 'you' : 'user'}.${expense.amount < 0 ? 'received' : 'owe'}`, - { - ns: 'common', - }, - )}{' '} - {expense.currency}{' '} - {toUIString( - (expense.paidBy === partecipant.userId ? (expense.amount ?? 0n) : 0n) - - partecipant.amount, - )} -

-
+ .filter((participant) => 0n !== participant.amount) + .map((participant) => ( + ))} + {expense.conversionTo && ( + <> + {expense.conversionTo.expenseParticipants + .filter((participant) => 0n !== participant.amount) + .map((participant) => ( + + ))} + + )}
); }; +const ExpenseParticipantEntry: React.FC<{ + participant: ExpenseDetailsOutput['expenseParticipants'][number]; + userId: number; + currency: string; +}> = ({ participant, userId, currency }) => { + const { displayName, t } = useTranslationWithUtils(); + + return ( +
+ +

+ {displayName(participant.user, userId)}{' '} + {t( + `ui.expense.${userId === participant.userId ? 'you' : 'user'}.${participant.amount < 0 ? 'received' : 'owe'}`, + )}{' '} + {currency} {toUIString(participant.amount)} +

+
+ ); +}; + +export const EditCurrencyConversion: React.FC<{ expense: ExpenseDetailsOutput }> = ({ + expense, +}) => { + const { setCurrency } = useAddExpenseStore((s) => s.actions); + + const addOrEditCurrencyConversionMutation = api.expense.addOrEditCurrencyConversion.useMutation(); + const apiUtils = api.useUtils(); + + const onClick = useCallback(() => { + if (expense.conversionTo && isCurrencyCode(expense.conversionTo.currency)) { + setCurrency(expense.conversionTo.currency); + } + }, [expense, setCurrency]); + + const sender = expense.paidByUser; + const receiver = expense.expenseParticipants.find((p) => p.userId !== expense.paidBy)?.user; + + if (!sender || !receiver || !isCurrencyCode(expense.currency)) { + return null; + } + + const onSubmit: ComponentProps['onSubmit'] = useCallback( + async (data) => { + await addOrEditCurrencyConversionMutation.mutateAsync({ + ...data, + senderId: sender.id, + receiverId: receiver.id, + groupId: expense.groupId, + expenseId: expense.id, + }); + await apiUtils.invalidate(); + }, + [ + addOrEditCurrencyConversionMutation, + sender.id, + receiver.id, + expense.groupId, + expense.id, + apiUtils, + ], + ); + + return ( + + + + ); +}; + export default ExpenseDetails; diff --git a/src/components/Expense/ExpenseList.tsx b/src/components/Expense/ExpenseList.tsx index 44c62600..fe73dc03 100644 --- a/src/components/Expense/ExpenseList.tsx +++ b/src/components/Expense/ExpenseList.tsx @@ -3,33 +3,43 @@ import { type inferRouterOutputs } from '@trpc/server'; import Image from 'next/image'; import Link from 'next/link'; import React from 'react'; -import { CategoryIcon } from '~/components/ui/categoryIcons'; +import { + CURRENCY_CONVERSION_ICON, + CategoryIcon, + SETTLEUP_ICON, +} from '~/components/ui/categoryIcons'; import type { ExpenseRouter } from '~/server/api/routers/expense'; import { toUIString } from '~/utils/numbers'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { api } from '~/utils/api'; + +type ExpensesOutput = + | inferRouterOutputs['getGroupExpenses'] + | inferRouterOutputs['getExpensesWithFriend']; + +type SingleExpenseOutput = ExpensesOutput[number]; + +type ExpenseComponent = React.FC<{ + e: SingleExpenseOutput; + userId: number; +}>; export const ExpenseList: React.FC<{ userId: number; - expenses?: - | inferRouterOutputs['getGroupExpenses'] - | inferRouterOutputs['getExpensesWithFriend']; + expenses?: ExpensesOutput; contactId: number; isGroup?: boolean; isLoading?: boolean; }> = ({ userId, isGroup = false, expenses = [], contactId, isLoading }) => { - const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + if (!isLoading && expenses.length === 0) { + return ; + } return ( <> {expenses.map((e) => { - const youPaid = e.paidBy === userId && e.amount >= 0n; - const yourExpense = e.expenseParticipants.find( - (partecipant) => partecipant.userId === userId, - ); const isSettlement = e.splitType === SplitType.SETTLEMENT; - const yourExpenseAmount = youPaid - ? (yourExpense?.amount ?? 0n) - : -(yourExpense?.amount ?? 0n); + const isCurrencyConversion = e.splitType === SplitType.CURRENCY_CONVERSION; return ( -
-
- {toUIDate(e.expenseDate)} -
-
- -
-
- {!isSettlement ? ( -

- {e.name} -

- ) : null} -

- {isSettlement ? ' 🎉 ' : null} - {displayName(e.paidByUser, userId)}{' '} - {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} - {e.currency} {toUIString(e.amount)} -

-
-
- {isSettlement ? null : ( -
- {youPaid || 0n !== yourExpenseAmount ? ( - <> -
- {t('ui.actors.you', { ns: 'common' })}{' '} - {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`, { ns: 'common' })} -
-
- {e.currency}{' '} - {toUIString(yourExpenseAmount)} -
- - ) : ( -
-

- {t('ui.not_involved', { ns: 'common' })} -

-
- )} -
- )} + {isSettlement && } + {isCurrencyConversion && } + {!isSettlement && !isCurrencyConversion && } ); })} - {0 === expenses.length && !isLoading ? ( -
- Empty + + ); +}; + +const Expense: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const youPaid = e.paidBy === userId && e.amount >= 0n; + const yourExpense = e.expenseParticipants.find((partecipant) => partecipant.userId === userId); + const yourExpenseAmount = youPaid ? (yourExpense?.amount ?? 0n) : -(yourExpense?.amount ?? 0n); + + return ( + <> +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

{e.name}

+

+ {displayName(e.paidByUser, userId)}{' '} + {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} + {e.currency} {toUIString(e.amount)} +

- ) : null} +
+
+ {youPaid || 0n !== yourExpenseAmount ? ( + <> +
+ {t('ui.actors.you', { ns: 'common' })}{' '} + {t(`ui.expense.you.${youPaid ? 'lent' : 'owe'}`, { ns: 'common' })} +
+
+ {e.currency} {toUIString(yourExpenseAmount)} +
+ + ) : ( +
+

{t('ui.not_involved', { ns: 'common' })}

+
+ )} +
); }; + +const Settlement: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const receiverId = e.expenseParticipants.find((p) => p.userId !== e.paidBy)?.userId; + const userDetails = api.user.getUserDetails.useQuery({ userId: receiverId! }); + + return ( +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

+ {displayName(e.paidByUser, userId)}{' '} + {t(`ui.expense.user.${e.amount < 0n ? 'received' : 'paid'}`, { ns: 'common' })}{' '} + {e.currency} {toUIString(e.amount)} {t('common:ui.expense.to')}{' '} + {displayName(userDetails.data, userId)} +

+
+
+ ); +}; + +const CurrencyConversion: ExpenseComponent = ({ e, userId }) => { + const { displayName, toUIDate, t } = useTranslationWithUtils(['expense_details']); + + const receiverId = e.expenseParticipants.find((p) => p.userId !== e.paidBy)?.userId; + const userDetails = api.user.getUserDetails.useQuery({ userId: receiverId! }); + + return ( +
+
+ {toUIDate(e.expenseDate)} +
+ +
+

+ {/* @ts-ignore */} + {e.currency} {toUIString(e.amount)} ➡️ {e.conversionTo.currency} {/* @ts-ignore */} + {toUIString(e.conversionTo.amount)} +

+

+ {t('common:ui.expense.for')} {displayName(e.paidByUser, userId)} {t('common:ui.and')}{' '} + {displayName(userDetails.data, userId)} +

+
+
+ ); +}; + +const NoExpenses = () => ( +
+ Empty +
+); diff --git a/src/components/Friend/CurrencyConversion.tsx b/src/components/Friend/CurrencyConversion.tsx new file mode 100644 index 00000000..5a4b4efc --- /dev/null +++ b/src/components/Friend/CurrencyConversion.tsx @@ -0,0 +1,257 @@ +import React, { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; +import { api } from '~/utils/api'; +import { BigMath, toSafeBigInt } from '~/utils/numbers'; + +import { toast } from 'sonner'; +import { env } from '~/env'; +import { type CurrencyCode, isCurrencyCode } from '~/lib/currency'; +import { useAddExpenseStore } from '~/store/addStore'; +import { CurrencyPicker } from '../AddExpense/CurrencyPicker'; +import { DateSelector } from '../AddExpense/DateSelector'; +import { Button } from '../ui/button'; +import { AppDrawer } from '../ui/drawer'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; + +export const CurrencyConversion: React.FC<{ + amount: bigint; + editingRate?: number; + editingTargetCurrency?: CurrencyCode; + currency: string; + children: ReactNode; + onSubmit: (data: { + from: CurrencyCode; + to: CurrencyCode; + amount: bigint; + rate: number; + }) => Promise | void; +}> = ({ amount, editingRate, editingTargetCurrency, currency, children, onSubmit }) => { + const { t } = useTranslationWithUtils(); + + const [amountStr, setAmountStr] = useState(''); + const [rate, setRate] = useState(''); + const [targetAmountStr, setTargetAmountStr] = useState(''); + const preferredCurrency = useAddExpenseStore((state) => state.currency); + const { setCurrency } = useAddExpenseStore((state) => state.actions); + const [targetCurrency, setTargetCurrency] = useState(preferredCurrency); + const [rateDate, setRateDate] = useState(new Date()); + const getCurrencyRate = api.expense.getCurrencyRate.useQuery( + { from: currency, to: targetCurrency, date: rateDate }, + { enabled: currency !== targetCurrency }, + ); + + useEffect(() => { + if (getCurrencyRate.isPending) { + setRate(''); + setTargetAmountStr(''); + } + }, [getCurrencyRate.isPending]); + + useEffect(() => { + setAmountStr((Number(BigMath.abs(amount)) / 100).toString()); + setRate(editingRate ? editingRate.toFixed(4) : ''); + if (editingTargetCurrency) { + setTargetCurrency(editingTargetCurrency); + } + }, [amount, editingRate, editingTargetCurrency]); + + useEffect(() => { + if (getCurrencyRate.data?.rate) { + setRate(getCurrencyRate.data.rate.toFixed(4)); + } + }, [getCurrencyRate.data, amountStr]); + + const dateDisabled = useMemo(() => ({ after: new Date() }), []); + + useEffect(() => { + setTargetAmountStr((Number(amountStr) * Number(rate)).toFixed(2)); + }, [amountStr, rate]); + + const onChangeAmount = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + if (Number(value) < 0 || Number.isNaN(Number(value))) { + return; + } + setAmountStr(value); + }, []); + + const onChangeRate = useCallback((e: React.ChangeEvent) => { + const raw = e.target.value.replace(',', '.'); + // Allow empty while typing + if (raw === '') { + setRate(''); + return; + } + // Only digits and optional dot + if (!/^[0-9]*\.?[0-9]*$/.test(raw)) { + return; + } + const [int = '', dec = ''] = raw.split('.'); + const trimmedDec = dec.slice(0, 4); + const normalized = raw.includes('.') ? `${int}.${trimmedDec}` : int; + setRate(normalized); + }, []); + + const onChangeTargetCurrency = useCallback( + (currency: CurrencyCode) => { + setRate(''); + setTargetCurrency(currency); + setCurrency(currency); + }, + [setCurrency], + ); + + const onChangeTargetAmount = useCallback( + (e: React.ChangeEvent) => { + const { value } = e.target; + if (Number(value) < 0 || Number.isNaN(Number(value))) { + return; + } + setAmountStr((Number(value) / Number(rate)).toFixed(2)); + }, + [rate], + ); + + const onSave = useCallback(async () => { + try { + if (!isCurrencyCode(currency)) { + toast.error(t('errors.invalid_currency_code', { code: currency })); + return; + } + + await onSubmit({ + amount: toSafeBigInt(amountStr), + rate: Number(rate), + from: currency, + to: targetCurrency, + }); + toast.success(t('ui.currency_conversion.success_toast')); + } catch (error) { + console.error(error); + toast.error(t('ui.currency_conversion.error_toast')); + } + }, [onSubmit, targetCurrency, amountStr, rate, currency, t]); + + return ( + +
+
+
+ {/* From amount */} +
+
+ + +
+ +
+ +
+
+ + {editingTargetCurrency ? ( + + ) : ( + + )} +
+ +
+ + {/* Rate */} +
+
+ +
+ + {getCurrencyRate.isPending && ( + + {t('ui.currency_conversion.fetching_rate')} + + )} + {!!rate && ( + <> + + 1 {currency} = {Number(rate).toFixed(4)} {targetCurrency} + + + 1 {targetCurrency} = {(1 / Number(rate)).toFixed(4)} {currency} + + + )} +
+
+
+ +
+ +
+
+
+
+
+
+
+ ); +}; diff --git a/src/components/GeneralPicker.tsx b/src/components/GeneralPicker.tsx index bb29a5b7..786a0fc3 100644 --- a/src/components/GeneralPicker.tsx +++ b/src/components/GeneralPicker.tsx @@ -1,10 +1,11 @@ import { Check } from 'lucide-react'; -import React from 'react'; -import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from './ui/command'; -import { AppDrawer, DrawerClose } from '~/components/ui/drawer'; +import React, { useCallback } from 'react'; +import { AppDrawer } from '~/components/ui/drawer'; import { cn } from '~/lib/utils'; +import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from './ui/command'; export const GeneralPicker: React.FC<{ + className?: string; trigger: React.ReactNode; onSelect: (value: string) => void; items: any[]; @@ -16,6 +17,7 @@ export const GeneralPicker: React.FC<{ noOptionsText: string; title: string; }> = ({ + className, trigger, onSelect, items, @@ -26,21 +28,43 @@ export const GeneralPicker: React.FC<{ noOptionsText, title, selected, -}) => ( - - - - - {noOptionsText} - {items.map((item) => ( - - +}) => { + const [open, setOpen] = React.useState(false); + + const onSelectAndClose: typeof onSelect = useCallback( + (value) => { + setOpen(false); + onSelect(value); + }, + [onSelect], + ); + + return ( + + + + + {noOptionsText} + {items.map((item) => ( +
{render(item)}
-
-
- ))} -
-
-
-); + + ))} + + + + ); +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index fa0d5a49..cdbcd422 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -62,11 +62,12 @@ const Button = React.forwardRef( buttonVariants({ variant, size, - className: - className + - (responsiveIcon + className: cn( + className, + responsiveIcon ? 'responsive-icon xs:gap-1 xs:text-sm xs:w-40 w-auto lg:w-[180px]' - : ''), + : '', + ), }), )} ref={ref} diff --git a/src/components/ui/categoryIcons.tsx b/src/components/ui/categoryIcons.tsx index dce5c54b..d6c65db6 100644 --- a/src/components/ui/categoryIcons.tsx +++ b/src/components/ui/categoryIcons.tsx @@ -1,3 +1,4 @@ +import { SplitType } from '@prisma/client'; import { Baby, Backpack, @@ -8,6 +9,7 @@ import { Car, CarTaxiFront, Construction, + DollarSign, DoorOpen, FerrisWheel, Flame, @@ -18,6 +20,7 @@ import { Globe, GraduationCap, Hammer, + HandCoins, HandIcon, Home, Hotel, @@ -98,9 +101,13 @@ export const CategoryIcons: Record = { hotel: Hotel, }; +export const CURRENCY_CONVERSION_ICON = DollarSign; + +export const SETTLEUP_ICON = HandCoins; + export const DEFAULT_CATEGORY_ICON = CategoryIcons[DEFAULT_CATEGORY]; -export const CategoryIcon: React.FC<{ category?: string } & LucideProps> = ({ +export const CategoryIcon: React.FC<{ category?: string; splitType?: SplitType } & LucideProps> = ({ category = DEFAULT_CATEGORY, ...props }) => { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 07f4ada0..193eef89 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -4,21 +4,28 @@ import { cn } from '~/lib/utils'; export type InputProps = React.InputHTMLAttributes; -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( +function Input({ + className, + type, + rightIcon, + ...props +}: React.ComponentProps<'input'> & { rightIcon?: React.ReactNode }) { + return ( +
- ); - }, -); -Input.displayName = 'Input'; + {rightIcon && {rightIcon}} +
+ ); +} export { Input }; diff --git a/src/env.js b/src/env.ts similarity index 91% rename from src/env.js rename to src/env.ts index 69401217..33808d13 100644 --- a/src/env.js +++ b/src/env.ts @@ -51,6 +51,10 @@ export const env = createEnv({ FEEDBACK_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), DEFAULT_HOMEPAGE: z.string().default('/home'), + CURRENCY_RATE_PROVIDER: z + .enum(['frankfurter', 'openexchangerates', 'nbp']) + .default('frankfurter'), + OPEN_EXCHANGE_RATES_APP_ID: z.string().optional(), OIDC_NAME: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(), @@ -63,7 +67,9 @@ export const env = createEnv({ * isn't built with invalid env vars. To expose them to the client, prefix them with * `NEXT_PUBLIC_`. */ - client: {}, + client: { + NEXT_PUBLIC_FRANKFURTER_USED: z.boolean().default(false), + }, /** * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. @@ -101,11 +107,14 @@ export const env = createEnv({ FEEDBACK_EMAIL: process.env.FEEDBACK_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, DEFAULT_HOMEPAGE: process.env.DEFAULT_HOMEPAGE, + CURRENCY_RATE_PROVIDER: process.env.CURRENCY_RATE_PROVIDER, + OPEN_EXCHANGE_RATES_APP_ID: process.env.OPEN_EXCHANGE_RATES_APP_ID, OIDC_NAME: process.env.OIDC_NAME, OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID, OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET, OIDC_WELL_KNOWN_URL: process.env.OIDC_WELL_KNOWN_URL, OIDC_ALLOW_DANGEROUS_EMAIL_LINKING: !!process.env.OIDC_ALLOW_DANGEROUS_EMAIL_LINKING, + NEXT_PUBLIC_FRANKFURTER_USED: process.env.CURRENCY_RATE_PROVIDER === 'frankfurter', }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/lib/currency.ts b/src/lib/currency.ts index 5c5ead36..d0807170 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -840,3 +840,38 @@ export const isCurrencyCode = (value: string): value is CurrencyCode => value in export const parseCurrencyCode = (code: string): CurrencyCode => isCurrencyCode(code) ? code : 'USD'; + +// Check with https://api.frankfurter.dev/v1/currencies +export const FRANKFURTER_CURRENCIES = [ + 'AUD', + 'BGN', + 'BRL', + 'CAD', + 'CHF', + 'CNY', + 'CZK', + 'DKK', + 'EUR', + 'GBP', + 'HKD', + 'HUF', + 'IDR', + 'ILS', + 'INR', + 'ISK', + 'JPY', + 'KRW', + 'MXN', + 'MYR', + 'NOK', + 'NZD', + 'PHP', + 'PLN', + 'RON', + 'SEK', + 'SGD', + 'THB', + 'TRY', + 'USD', + 'ZAR', +]; diff --git a/src/pages/add.tsx b/src/pages/add.tsx index ef3319dd..61baaafc 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -43,8 +43,7 @@ const AddPage: NextPageWithUser<{ email: user.email ?? null, image: user.image ?? null, }); - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [setCurrentUser, user]); const router = useRouter(); const { friendId, groupId, expenseId } = router.query; @@ -80,16 +79,14 @@ const AddPage: NextPageWithUser<{ ]); useAddExpenseStore.setState({ showFriends: false }); } - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [groupId, groupQuery.isPending, groupQuery.data, currentUser]); + }, [groupId, groupQuery.isPending, groupQuery.data, currentUser, setGroup, setParticipants]); useEffect(() => { if (friendId && currentUser && friendQuery.data) { setParticipants([currentUser, friendQuery.data]); useAddExpenseStore.setState({ showFriends: false }); } - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [friendId, friendQuery.isPending, friendQuery.data, currentUser]); + }, [friendId, friendQuery.isPending, friendQuery.data, currentUser, setParticipants]); useEffect(() => { if (!_expenseId || !expenseQuery.data) { @@ -114,8 +111,19 @@ const AddPage: NextPageWithUser<{ ); useAddExpenseStore.setState({ showFriends: false }); setExpenseDate(expenseQuery.data.expenseDate); - // oxlint-disable-next-line react-hooks/exhaustive-deps - }, [_expenseId, expenseQuery.data]); + }, [ + _expenseId, + expenseQuery.data, + setAmount, + setAmountStr, + setCategory, + setCurrency, + setDescription, + setExpenseDate, + setGroup, + setPaidBy, + setParticipants, + ]); return ( <> diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index f37bb5b3..bb97f091 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -39,6 +39,9 @@ const providerSvgs = { keycloak: , }; +const providerTypeGuard = (providerId: string): providerId is keyof typeof providerSvgs => + providerId in providerSvgs; + const emailSchema = (t: TFunction) => z.object({ email: z @@ -153,7 +156,7 @@ const Home: NextPage<{ onClick={handleProviderSignIn(provider.id)} key={provider.id} > - {providerSvgs[provider.id as keyof typeof providerSvgs]} + {providerTypeGuard(provider.id) && providerSvgs[provider.id]} {t('auth.continue_with', { provider: provider.name })} ))} diff --git a/src/pages/balances/[friendId]/expenses/[expenseId].tsx b/src/pages/balances/[friendId]/expenses/[expenseId].tsx index 7e6cf822..c99cabbe 100644 --- a/src/pages/balances/[friendId]/expenses/[expenseId].tsx +++ b/src/pages/balances/[friendId]/expenses/[expenseId].tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { env } from '~/env'; @@ -12,6 +12,7 @@ import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { SplitType } from '@prisma/client'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -45,11 +46,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ friendId={friendId} groupId={expenseQuery.data?.groupId ?? undefined} /> - - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
} > diff --git a/src/pages/expenses/[expenseId].tsx b/src/pages/expenses/[expenseId].tsx index d0e98239..efb6e64a 100644 --- a/src/pages/expenses/[expenseId].tsx +++ b/src/pages/expenses/[expenseId].tsx @@ -1,13 +1,14 @@ import { env } from 'process'; +import { SplitType } from '@prisma/client'; import { ChevronLeftIcon, PencilIcon } from 'lucide-react'; +import { type GetServerSideProps } from 'next'; +import { useTranslation } from 'next-i18next'; import Head from 'next/head'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useTranslation } from 'next-i18next'; -import { type GetServerSideProps } from 'next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { type NextPageWithUser } from '~/types'; @@ -43,11 +44,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ {!expenseQuery.data?.deletedBy ? (
- - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
) : null}
diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index ace4508d..a33d9537 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -506,6 +506,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { 'groups_details', 'expense_details', 'friend_details', + 'currencies', ])), enableSendingInvites: env.ENABLE_SENDING_INVITES, }, diff --git a/src/pages/groups/[groupId]/expenses/[expenseId].tsx b/src/pages/groups/[groupId]/expenses/[expenseId].tsx index 10a214a5..aca1af5c 100644 --- a/src/pages/groups/[groupId]/expenses/[expenseId].tsx +++ b/src/pages/groups/[groupId]/expenses/[expenseId].tsx @@ -6,13 +6,14 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; import { DeleteExpense } from '~/components/Expense/DeleteExpense'; -import ExpenseDetails from '~/components/Expense/ExpenseDetails'; +import ExpenseDetails, { EditCurrencyConversion } from '~/components/Expense/ExpenseDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { Button } from '~/components/ui/button'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; import { customServerSideTranslations } from '~/utils/i18n/server'; import { type GetServerSideProps } from 'next'; +import { SplitType } from '@prisma/client'; const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ user, @@ -48,11 +49,15 @@ const ExpensesPage: NextPageWithUser<{ storagePublicUrl?: string }> = ({ expenseId={expenseId} groupId={expenseQuery.data?.groupId ?? undefined} /> - - - + {expenseQuery.data?.splitType !== SplitType.CURRENCY_CONVERSION ? ( + + + + ) : ( + + )}
} loading={expenseQuery.isPending} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index bf0eaf59..d72b0f2e 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -2,6 +2,7 @@ import { ArrowRight, Banknote, Bell, + DollarSign, FileUp, GitFork, Globe, @@ -204,6 +205,18 @@ export default function Home({ isCloud }: { isCloud: boolean }) {

{t('features.i18.description')}

+ +
+
+
+ +

{t('features.currency_conversion.title')}

+
+

+ {t('features.currency_conversion.description')} +

+
+
diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 49e011ac..d6f26374 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -7,11 +7,18 @@ import { FILE_SIZE_LIMIT } from '~/lib/constants'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { getDocumentUploadUrl } from '~/server/storage'; -import { BigMath } from '~/utils/numbers'; +import { BigMath, currencyConversion } from '~/utils/numbers'; -// import { sendExpensePushNotification } from '../services/notificationService'; -import { createExpenseSchema } from '~/types/expense.types'; +import { + createCurrencyConversionSchema, + createExpenseSchema, + getCurrencyRateSchema, +} from '~/types/expense.types'; import { createExpense, deleteExpense, editExpense } from '../services/splitService'; +import { currencyRateProvider } from '../services/currencyRateService'; +import { isCurrencyCode } from '~/lib/currency'; +import { SplitType } from '@prisma/client'; +import { DEFAULT_CATEGORY } from '~/lib/category'; export const expenseRouter = createTRPCRouter({ getBalances: protectedProcedure.query(async ({ ctx }) => { @@ -28,25 +35,22 @@ export const expenseRouter = createTRPCRouter({ }); const balances = balancesRaw - .reduce( - (acc, current) => { - const existing = acc.findIndex((item) => item.friendId === current.friendId); - if (-1 === existing) { - acc.push(current); - } else { - const existingItem = acc[existing]; - if (existingItem) { - if (BigMath.abs(existingItem.amount) > BigMath.abs(current.amount)) { - acc[existing] = { ...existingItem, hasMore: true }; - } else { - acc[existing] = { ...current, hasMore: true }; - } + .reduce<((typeof balancesRaw)[number] & { hasMore?: boolean })[]>((acc, current) => { + const existing = acc.findIndex((item) => item.friendId === current.friendId); + if (-1 === existing) { + acc.push(current); + } else { + const existingItem = acc[existing]; + if (existingItem) { + if (BigMath.abs(existingItem.amount) > BigMath.abs(current.amount)) { + acc[existing] = { ...existingItem, hasMore: true }; + } else { + acc[existing] = { ...current, hasMore: true }; } } - return acc; - }, - [] as ((typeof balancesRaw)[number] & { hasMore?: boolean })[], - ) + } + return acc; + }, []) .sort((a, b) => Number(BigMath.abs(b.amount) - BigMath.abs(a.amount))); const cumulatedBalances = await db.balance.groupBy({ @@ -80,6 +84,9 @@ export const expenseRouter = createTRPCRouter({ if (input.expenseId) { await validateEditExpensePermission(input.expenseId, ctx.session.user.id); } + if (input.splitType === SplitType.CURRENCY_CONVERSION) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); + } if (input.groupId) { const group = await db.group.findUnique({ @@ -106,6 +113,86 @@ export const expenseRouter = createTRPCRouter({ } }), + addOrEditCurrencyConversion: protectedProcedure + .input(createCurrencyConversionSchema) + .mutation(async ({ input, ctx }) => { + const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; + + const amountTo = currencyConversion(amount, rate); + const name = `${from} → ${to} @ ${rate}`; + + const expenseFrom = await (expenseId ? editExpense : createExpense)( + { + expenseId, + name, + currency: from, + amount, + paidBy: senderId, + splitType: SplitType.CURRENCY_CONVERSION, + category: DEFAULT_CATEGORY, + participants: [ + { userId: senderId, amount: amount }, + { userId: receiverId, amount: -amount }, + ], + groupId, + expenseDate: new Date(), + }, + ctx.session.user.id, + ); + + if (!expenseFrom) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to upsert currency conversion record', + }); + } + + const otherConversionParams = { + name, + currency: to, + amount: amountTo, + paidBy: receiverId, + splitType: SplitType.CURRENCY_CONVERSION, + category: DEFAULT_CATEGORY, + participants: [ + { userId: senderId, amount: -amountTo }, + { userId: receiverId, amount: amountTo }, + ], + groupId, + expenseDate: new Date(), + }; + + if (expenseId) { + const expense = await db.expense.findFirst({ + select: { otherConversion: true }, + where: { + id: expenseId, + }, + }); + + if (expense?.otherConversion) { + await editExpense( + { + expenseId: expense.otherConversion, + ...otherConversionParams, + }, + ctx.session.user.id, + ); + return { + ...expenseFrom, + }; + } + } else { + await createExpense( + { + ...otherConversionParams, + otherConversion: expenseFrom.id, + }, + ctx.session.user.id, + ); + } + }), + getExpensesWithFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .query(async ({ input, ctx }) => { @@ -133,6 +220,20 @@ export const expenseRouter = createTRPCRouter({ { deletedBy: null, }, + { + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + otherConversion: null, + }, + }, + ], + }, ], }, orderBy: { @@ -152,6 +253,7 @@ export const expenseRouter = createTRPCRouter({ }, }, paidByUser: true, + conversionFrom: true, }, }); @@ -165,6 +267,18 @@ export const expenseRouter = createTRPCRouter({ where: { groupId: input.groupId, deletedBy: null, + OR: [ + { + NOT: { + splitType: SplitType.CURRENCY_CONVERSION, + }, + }, + { + NOT: { + otherConversion: null, + }, + }, + ], }, orderBy: { expenseDate: 'desc', @@ -173,6 +287,7 @@ export const expenseRouter = createTRPCRouter({ expenseParticipants: true, paidByUser: true, deletedByUser: true, + conversionTo: true, }, }); @@ -198,6 +313,15 @@ export const expenseRouter = createTRPCRouter({ deletedByUser: true, updatedByUser: true, group: true, + conversionTo: { + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + }, + }, }, }); @@ -318,6 +442,18 @@ export const expenseRouter = createTRPCRouter({ await deleteExpense(input.expenseId, ctx.session.user.id); }), + + getCurrencyRate: protectedProcedure.input(getCurrencyRateSchema).query(async ({ input }) => { + const { from, to, date } = input; + + if (!isCurrencyCode(from) || !isCurrencyCode(to)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); + } + + const rate = await currencyRateProvider.getCurrencyRate(from, to, date); + + return { rate }; + }), }); const validateEditExpensePermission = async (expenseId: string, userId: number): Promise => { diff --git a/src/server/api/services/currencyRateService.ts b/src/server/api/services/currencyRateService.ts new file mode 100644 index 00000000..f52f254d --- /dev/null +++ b/src/server/api/services/currencyRateService.ts @@ -0,0 +1,242 @@ +import { format, getISODay, isToday, subDays } from 'date-fns'; +import { env } from '~/env'; +import type { CurrencyCode } from '~/lib/currency'; +import { db } from '~/server/db'; + +export interface RateResponse { + base: string; + rates: { [key: string]: number }; +} + +class ProviderMissingError extends Error {} + +abstract class CurrencyRateProvider { + intermediateBase: CurrencyCode | null = null; + abstract providerName: string; + + abstract fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise; + + public async getCurrencyRate( + from: CurrencyCode, + to: CurrencyCode, + date: Date = new Date(), + ): Promise { + if (from === to) { + return 1; + } + + const cachedRate = await this.checkCache(from, to, date); + if (cachedRate) { + return cachedRate; + } + + const data = await this.fetchRates(from, to, date); + + await Promise.all( + Object.entries(data.rates).map(([to, rate]) => + db.currencyRateCache.upsert({ + where: { + from_to_date: { from: data.base, to, date }, + }, + create: { + from: data.base, + to, + date, + rate, + }, + update: { + rate, + }, + }), + ), + ); + + const res = await this.checkCache(from, to, date); + if (res) { + return res; + } + + throw new Error('Failed to retrieve currency rate'); + } + + protected async checkCache( + from: CurrencyCode, + to: CurrencyCode, + date: Date = new Date(), + ): Promise { + const cachedRate = await this.getCache(from, to, date); + + if (cachedRate) { + return cachedRate.rate; + } + + const reverseCachedRate = await this.getCache(to, from, date); + + if (reverseCachedRate) { + void this.upsertCache(from, to, date, 1 / reverseCachedRate.rate); + return 1 / reverseCachedRate.rate; + } + + if ([null, from, to].includes(this.intermediateBase)) { + return undefined; + } + + // try with intermediate base currency + const rateFromIntermediate = await this.checkCache(this.intermediateBase!, from, date); + const rateToIntermediate = await this.checkCache(this.intermediateBase!, to, date); + + if (rateFromIntermediate && rateToIntermediate) { + const rate = rateToIntermediate / rateFromIntermediate; + void this.upsertCache(from, to, date, rate); + return rate; + } + } + + private upsertCache(from: CurrencyCode, to: CurrencyCode, date: Date, rate: number) { + return db.currencyRateCache.upsert({ + where: { + from_to_date: { from, to, date }, + }, + create: { + from, + to, + date, + rate, + }, + update: { + rate, + }, + }); + } + + private async getCache(from: CurrencyCode, to: CurrencyCode, date: Date) { + const result = await db.currencyRateCache.findUnique({ + where: { + from_to_date: { from, to, date }, + }, + }); + if (result) { + void db.currencyRateCache.update({ + where: { + from_to_date: { from, to, date }, + }, + data: { + insertedAt: new Date(), + }, + }); + } + return result; + } +} + +class FrankfurterProvider extends CurrencyRateProvider { + providerName = 'frankfurter'; + + async fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise { + const key = !date || isToday(date) ? 'latest' : format(date, 'yyyy-MM-dd'); + + const response = await fetch( + `https://api.frankfurter.dev/v1/${key}?base=${from}&symbols=${to}`, + ); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); + } +} + +class OpenExchangeRatesProvider extends CurrencyRateProvider { + providerName = 'openexchangerates'; + intermediateBase: CurrencyCode = 'USD'; + + async fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise { + if (!process.env.OPEN_EXCHANGE_RATES_APP_ID) { + throw new ProviderMissingError('Open Exchange Rates API key not provided'); + } + const key = !date || isToday(date) ? 'latest' : `historical/${format(date, 'yyyy-MM-dd')}`; + + // sadly the free tier supports only USD as base currency + const response = await fetch( + `https://openexchangerates.org/api/${key}.json?app_id=${process.env.OPEN_EXCHANGE_RATES_APP_ID}`, + ); + const data: RateResponse = await response.json(); + + if (response.ok) { + return data; + } + + throw new Error(response.statusText || 'Failed to fetch exchange rates'); + } +} + +class NbpProvider extends CurrencyRateProvider { + providerName = 'nbp'; + intermediateBase: CurrencyCode = 'PLN'; + + async fetchRates(from: CurrencyCode, to: CurrencyCode, date?: Date): Promise { + const key = !date || isToday(date) ? '' : format(date, 'yyyy-MM-dd'); + + const response = await this.getBothTables(key); + + return { + base: 'PLN', + rates: Object.fromEntries(response.rates.map((rate) => [rate.code, rate.mid])), + }; + } + + private async getBothTables(date: string) { + const [tableA, tableB] = await Promise.all([ + this.getRates('A', date), + this.getRates('B', date), + ]); + + return { + ...tableA[0], + rates: [...(tableA[0]?.rates || []), ...(tableB[0]?.rates || [])], + }; + } + + private async getRates( + table: 'A' | 'B', + date: string, + ): Promise< + { + table: string; + no: string; + effectiveDate: string; + rates: { currency: string; code: string; mid: number }[]; + }[] + > { + const response = await fetch( + `https://api.nbp.pl/api/exchangerates/tables/${table}/${date}/?format=json`, + ); + if (!response.ok) { + if (table === 'A') { + throw new Error(response.statusText || 'Failed to fetch exchange rates'); + } else { + // table B is published weekly on Wednesdays + const currentIsoDay = getISODay(date); + const previousWednesday = subDays( + date, + currentIsoDay >= 3 ? currentIsoDay - 3 : 7 - (3 - currentIsoDay), + ); + return this.getRates(table, format(previousWednesday, 'yyyy-MM-dd')); + } + } + + return response.json(); + } +} + +export const currencyRateProvider = (() => { + if (env.CURRENCY_RATE_PROVIDER === 'openexchangerates' && env.OPEN_EXCHANGE_RATES_APP_ID) { + return new OpenExchangeRatesProvider(); + } else if (env.CURRENCY_RATE_PROVIDER === 'nbp') { + return new NbpProvider(); + } + + return new FrankfurterProvider(); +})(); diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 0565d2b2..cc742257 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, + otherConversion, }: CreateExpense, currentUserId: number, ) { @@ -66,6 +67,13 @@ export async function createExpense( fileKey, addedBy: currentUserId, expenseDate, + conversionFrom: otherConversion + ? { + connect: { + id: otherConversion ?? null, + }, + } + : undefined, }, }), ); @@ -207,6 +215,10 @@ export async function deleteExpense(expenseId: string, deletedBy: number) { throw new Error('Expense not found'); } + if (expense.otherConversion) { + await deleteExpense(expense.otherConversion, deletedBy); + } + expense.expenseParticipants .filter(({ userId }) => userId !== expense.paidBy) .forEach((participant) => { diff --git a/src/types/expense.types.ts b/src/types/expense.types.ts index 11981a6d..9537c59f 100644 --- a/src/types/expense.types.ts +++ b/src/types/expense.types.ts @@ -12,10 +12,12 @@ export type CreateExpense = Omit< | 'deletedBy' | 'expenseDate' | 'fileKey' + | 'otherConversion' > & { expenseDate?: Date; fileKey?: string; expenseId?: string; + otherConversion?: string; participants: Omit[]; }; @@ -32,10 +34,30 @@ export const createExpenseSchema = z.object({ SplitType.SHARE, SplitType.EXACT, SplitType.SETTLEMENT, + SplitType.CURRENCY_CONVERSION, ]), currency: z.string(), participants: z.array(z.object({ userId: z.number(), amount: z.bigint() })), fileKey: z.string().optional(), expenseDate: z.date().optional(), expenseId: z.string().optional(), + otherConversion: z.string().optional(), }) satisfies z.ZodType; + +export const createCurrencyConversionSchema = z.object({ + amount: z.bigint(), + from: z.string(), + to: z.string(), + rate: z.number().positive(), + senderId: z.number(), + receiverId: z.number(), + groupId: z.number().nullable(), + expenseId: z.string().optional(), + otherExpenseId: z.string().optional(), +}); + +export const getCurrencyRateSchema = z.object({ + from: z.string(), + to: z.string(), + date: z.date().transform((date) => new Date(date.toDateString())), +}); diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 9d495d5c..da0d3e7a 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -50,6 +50,10 @@ export function removeTrailingZeros(num: string) { return num; } +export function currencyConversion(amount: bigint, rate: number) { + return BigMath.roundDiv(amount * BigInt(Math.round(rate * 10000)), 10000n); +} + export const BigMath = { abs(x: bigint) { return 0n > x ? -x : x; @@ -89,6 +93,22 @@ export const BigMath = { return value; } }, + roundDiv(x: bigint, y: bigint) { + if (0n === y) { + throw new Error('Division by zero'); + } + const absRemainderDoubled = (BigMath.abs(x) % BigMath.abs(y)) * 2n; + const q = x / y; + return ( + q + + (absRemainderDoubled < BigMath.abs(y) || + (absRemainderDoubled === BigMath.abs(y) && q % 2n === 0n) + ? 0n + : x > 0n === y > 0n + ? 1n + : -1n) + ); + }, }; export const bigIntReplacer = (key: string, value: any): any =>