diff --git a/README.md b/README.md index 8e82ce58..051847a5 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ Recurring expenses require a PostgreSQL database with the `pg_cron` extension. W Bank integration allows you to load transactions from providers like Plaid and convert them into expenses. This feature was provided by @alexanderwassbjer, who is currently maintaining related issues. See [docs/BANK_TRANSACTIONS.md](docs/BANK_TRANSACTIONS.md). +### 10) REST API + +SplitPro exposes a REST API for integrations with external tools. Generate an API key in Account Settings, then authenticate with the `X-API-Key` header. The OpenAPI spec is available at `/api/openapi.json` and interactive docs at `/api/docs`. + +```bash +# Example: list expenses +curl -H "X-API-Key: sp_YOUR_KEY" https://your-instance.com/api/v1/expenses +``` + +Endpoints cover users, friends, groups, balances, and full expense CRUD. + ## Limitations and notes - SplitPro computes balances from expenses on the fly using database views. Expenses are the source of truth, which keeps balances consistent and trustworthy. For self hosted deployments the efficiency of database aggregations is entirely sufficient, but please do report any performance issues. diff --git a/package.json b/package.json index 8ef2a1e1..a84d17ce 100644 --- a/package.json +++ b/package.json @@ -72,9 +72,11 @@ "sharp": "^0.34.5", "sonner": "^1.4.0", "superjson": "^2.2.1", + "trpc-to-openapi": "2.4.0", "vaul": "^1.1.2", "web-push": "^3.6.7", "zod": "^3.22.4", + "zod-openapi": "4.2.4", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8692877..a1557168 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,9 @@ importers: superjson: specifier: ^2.2.1 version: 2.2.6 + trpc-to-openapi: + specifier: 2.4.0 + version: 2.4.0(@trpc/server@11.8.0(typescript@5.7.3))(zod-openapi@4.2.4(zod@3.25.76))(zod@3.25.76) vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -169,6 +172,9 @@ importers: zod: specifier: ^3.22.4 version: 3.25.76 + zod-openapi: + specifier: 4.2.4 + version: 4.2.4(zod@3.25.76) zustand: specifier: ^4.5.0 version: 4.5.7(@types/react@19.2.1)(react@19.2.1) @@ -664,6 +670,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hapi/bourne@3.0.0': + resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -1947,6 +1956,11 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rollup/rollup-linux-x64-gnu@4.6.1': + resolution: {integrity: sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA==} + cpu: [x64] + os: [linux] + '@serwist/build@9.2.3': resolution: {integrity: sha512-UU38GDsTerzoCRDIT5v62W/CcTMLfGZm/tAa+u8XLBU0y0f2aJ2GCfsHnI1eXhEuWvt4Y7Imx/uG2ww2KWIBWQ==} engines: {node: '>=18.0.0'} @@ -2518,6 +2532,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2530,6 +2548,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2603,6 +2625,10 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + co-body@6.2.0: + resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} + engines: {node: '>=8.0.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2642,6 +2668,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2671,6 +2700,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2726,6 +2758,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2967,6 +3003,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@1.15.1: + resolution: {integrity: sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2996,6 +3035,10 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -3034,6 +3077,10 @@ packages: typescript: optional: true + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3050,6 +3097,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflation@2.1.0: + resolution: {integrity: sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ==} + engines: {node: '>= 0.8.0'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3063,6 +3114,9 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3455,6 +3509,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3578,6 +3636,9 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -3618,6 +3679,10 @@ packages: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -3636,6 +3701,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + openapi3-ts@4.4.0: + resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==} + openid-client@5.7.1: resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} @@ -3847,6 +3915,10 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -3860,6 +3932,13 @@ packages: '@types/react-dom': optional: true + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -4017,6 +4096,9 @@ packages: typescript: optional: true + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4029,6 +4111,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4073,6 +4171,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -4181,6 +4283,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4195,6 +4301,13 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trpc-to-openapi@2.4.0: + resolution: {integrity: sha512-B6xrwOC3Ab0q1BWD/QbJzK4OUpCLoT02hAzshSUXEuIZGcJZkMG/OJ4/3gd20dyr8aI+CrFirpWKRIo7JmHbMQ==} + peerDependencies: + '@trpc/server': ^11.1.0 + zod: ^3.23.8 + zod-openapi: 4.2.4 + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -4225,14 +4338,28 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -4407,6 +4534,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-openapi@4.2.4: + resolution: {integrity: sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.21.4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4779,6 +4912,8 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hapi/bourne@3.0.0': {} + '@heroicons/react@2.2.0(react@19.2.1)': dependencies: react: 19.2.1 @@ -6098,6 +6233,9 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rollup/rollup-linux-x64-gnu@4.6.1': + optional: true + '@serwist/build@9.2.3(typescript@5.7.3)': dependencies: common-tags: 1.8.2 @@ -6612,6 +6750,8 @@ snapshots: buffer-from@1.1.2: {} + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -6632,6 +6772,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelcase@5.3.1: {} @@ -6698,6 +6843,14 @@ snapshots: - '@types/react' - '@types/react-dom' + co-body@6.2.0: + dependencies: + '@hapi/bourne': 3.0.0 + inflation: 2.1.0 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + co@4.6.0: {} collect-v8-coverage@1.0.2: {} @@ -6724,6 +6877,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + cookie@0.7.2: {} copy-anything@4.0.5: @@ -6752,6 +6907,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -6786,6 +6945,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -7046,6 +7207,18 @@ snapshots: graceful-fs@4.2.11: {} + h3@1.15.1: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.4 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -7072,6 +7245,14 @@ snapshots: dependencies: void-elements: 3.1.0 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 @@ -7110,6 +7291,10 @@ snapshots: optionalDependencies: typescript: 5.7.3 + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -7123,6 +7308,8 @@ snapshots: imurmurhash@0.1.4: {} + inflation@2.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -7135,6 +7322,8 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) + iron-webcrypto@1.2.1: {} + is-arrayish@0.2.1: {} is-fullwidth-code-point@3.0.0: {} @@ -7726,6 +7915,8 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -7828,6 +8019,8 @@ snapshots: node-int64@0.4.0: {} + node-mock-http@1.0.4: {} + node-releases@2.0.27: {} nodemailer@9.0.1: {} @@ -7863,6 +8056,8 @@ snapshots: object-hash@2.2.0: {} + object-inspect@1.13.4: {} + ohash@2.0.11: {} oidc-token-hash@5.1.0: {} @@ -7879,6 +8074,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + openapi3-ts@4.4.0: + dependencies: + yaml: 2.8.3 + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -8046,6 +8245,11 @@ snapshots: pure-rand@7.0.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + radix-ui@1.4.3(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@radix-ui/primitive': 1.1.3 @@ -8109,6 +8313,15 @@ snapshots: '@types/react': 19.2.1 '@types/react-dom': 19.2.1(@types/react@19.2.1) + radix3@1.1.2: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -8238,6 +8451,8 @@ snapshots: optionalDependencies: typescript: 5.7.3 + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -8275,6 +8490,34 @@ snapshots: shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8310,6 +8553,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + statuses@2.0.2: {} + string-argv@0.3.2: {} string-length@4.0.2: @@ -8405,6 +8650,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -8419,6 +8666,17 @@ snapshots: dependencies: punycode: 2.3.1 + trpc-to-openapi@2.4.0(@trpc/server@11.8.0(typescript@5.7.3))(zod-openapi@4.2.4(zod@3.25.76))(zod@3.25.76): + dependencies: + '@trpc/server': 11.8.0(typescript@5.7.3) + co-body: 6.2.0 + h3: 1.15.1 + openapi3-ts: 4.4.0 + zod: 3.25.76 + zod-openapi: 4.2.4(zod@3.25.76) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.6.1 + ts-node@10.9.2(@types/node@22.19.3)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -8450,10 +8708,21 @@ snapshots: type-fest@0.21.3: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typescript@5.7.3: {} + ufo@1.6.4: {} + + uncrypto@0.1.3: {} + undici-types@6.21.0: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.3 @@ -8631,6 +8900,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-openapi@4.2.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zod@4.1.12: {} diff --git a/prisma/migrations/20260701220715_add_api_keys/migration.sql b/prisma/migrations/20260701220715_add_api_keys/migration.sql new file mode 100644 index 00000000..34088913 --- /dev/null +++ b/prisma/migrations/20260701220715_add_api_keys/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576ece26..26b1d743 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,6 +65,7 @@ model User { friendDefaultSplitsB FriendDefaultSplit[] @relation("FriendDefaultSplitUserB") sessions Session[] hiddenFriendIds Int[] @default([]) + apiKeys ApiKey[] @@schema("public") } @@ -270,6 +271,19 @@ model PushNotification { @@schema("public") } +model ApiKey { + id String @id @default(cuid()) + userId Int + name String + keyHash String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastUsedAt DateTime? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@schema("public") +} + model AppMetadata { key String @id value String diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 590275bc..7a010a59 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -62,6 +62,23 @@ "support_us": "Sponsor us", "title": "Account", "write_review": "Write a review", + "api_keys": "API Keys", + "api_key": { + "title": "API Keys", + "description": "Generate API keys to access the SplitPro REST API. Use the X-API-Key header to authenticate.", + "generate": "Generate", + "name_placeholder": "Key name (e.g., Personal, Zapier)", + "name_required": "Please enter a key name", + "new_key": "New API Key", + "copy_warning": "Copy this now — you won't be able to see it again.", + "created": "Created", + "last_used": "Last used", + "never_used": "Never used", + "deleted": "API key deleted", + "delete_error": "Failed to delete API key", + "create_error": "Failed to create API key", + "no_keys": "No API keys yet" + }, "debug_info": "Debug info", "debug_info_details": { "title": "Debug Information", diff --git a/src/components/Account/ApiKeyManager.tsx b/src/components/Account/ApiKeyManager.tsx new file mode 100644 index 00000000..f0011c6e --- /dev/null +++ b/src/components/Account/ApiKeyManager.tsx @@ -0,0 +1,155 @@ +import React, { useCallback, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { api } from '~/utils/api'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '../ui/alert-dialog'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { toast } from 'sonner'; +import { Copy, Trash2 } from 'lucide-react'; + +export const ApiKeyManager: React.FC = ({ children }) => { + const { t } = useTranslation(); + const utils = api.useUtils(); + const [keyName, setKeyName] = useState(''); + const [newKey, setNewKey] = useState(null); + + const getApiKeys = api.user.getApiKeys.useQuery(); + const createApiKey = api.user.createApiKey.useMutation({ + onSuccess: async (data) => { + setNewKey(data.key); + await utils.user.getApiKeys.refetch(); + }, + }); + const deleteApiKey = api.user.deleteApiKey.useMutation({ + onSuccess: async () => { + await utils.user.getApiKeys.refetch(); + }, + }); + + const onCreate = useCallback(async () => { + if (!keyName.trim()) { + toast.error(t('account.api_key.name_required')); + return; + } + try { + await createApiKey.mutateAsync({ name: keyName.trim() }); + setKeyName(''); + } catch { + toast.error(t('account.api_key.create_error')); + } + }, [createApiKey, keyName, t]); + + const onDelete = useCallback( + async (id: string) => { + try { + await deleteApiKey.mutateAsync({ id }); + toast.success(t('account.api_key.deleted')); + } catch { + toast.error(t('account.api_key.delete_error')); + } + }, + [deleteApiKey, t], + ); + + const copyToClipboard = useCallback( + (text: string) => { + navigator.clipboard.writeText(text).catch(console.error); + toast.success(t('group_details.copied')); + }, + [t], + ); + + return ( + + {children} + + + {t('account.api_key.title')} + {t('account.api_key.description')} + + +
+ {newKey && ( +
+

{t('account.api_key.new_key')}:

+
+ + {newKey} + + +
+

{t('account.api_key.copy_warning')}

+
+ )} + +
+ setKeyName(e.target.value)} + onKeyDown={(e) => { + if ('Enter' === e.key) { + void onCreate(); + } + }} + /> + +
+ +
+ {getApiKeys.data?.map((key) => ( +
+
+ {key.name} + + {t('account.api_key.created')}: {new Date(key.createdAt).toLocaleDateString()} + {key.lastUsedAt + ? ` · ${t('account.api_key.last_used')}: ${new Date(key.lastUsedAt).toLocaleDateString()}` + : ` · ${t('account.api_key.never_used')}`} + +
+ +
+ ))} + {getApiKeys.data && 0 === getApiKeys.data.length && ( +

{t('account.api_key.no_keys')}

+ )} +
+
+ + + { + setNewKey(null); + setKeyName(''); + }} + > + {t('actions.close')} + + +
+
+ ); +}; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 3f37ad26..2ff9e416 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -6,6 +6,7 @@ import { DownloadCloud, FileDown, HeartHandshakeIcon, + Key, Languages, Star, } from 'lucide-react'; @@ -17,6 +18,7 @@ import { useRouter } from 'next/router'; import { useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { AccountButton } from '~/components/Account/AccountButton'; +import { ApiKeyManager } from '~/components/Account/ApiKeyManager'; import { DownloadAppDrawer } from '~/components/Account/DownloadAppDrawer'; import { LanguagePicker } from '~/components/Account/LanguagePicker'; import { SubmitFeedback } from '~/components/Account/SubmitFeedback'; @@ -155,6 +157,13 @@ const AccountPage: NextPageWithUser<{ {t('account.write_review')} + + + + {t('account.api_keys')} + + + diff --git a/src/pages/api/docs.ts b/src/pages/api/docs.ts new file mode 100644 index 00000000..169d90b5 --- /dev/null +++ b/src/pages/api/docs.ts @@ -0,0 +1,32 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const html = ` + + + + + SplitPro API Documentation + + + + +
+ + + +`; + + res.setHeader('Content-Type', 'text/html'); + res.status(200).send(html); +} diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts new file mode 100644 index 00000000..0f3b097f --- /dev/null +++ b/src/pages/api/openapi.json.ts @@ -0,0 +1,24 @@ +import { type NextApiRequest, type NextApiResponse } from 'next'; +import { generateOpenApiDocument } from 'trpc-to-openapi'; + +import { appRouter } from '~/server/api/root'; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const openApiDocument = generateOpenApiDocument(appRouter, { + title: 'SplitPro REST API', + description: 'Official REST API for SplitPro. Authenticate with X-API-Key header.', + version: '1.0.0', + baseUrl: `${process.env.NEXTAUTH_URL ?? 'http://localhost:3000'}/api/v1`, + tags: ['User', 'Group', 'Expense'], + securitySchemes: { + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + }, + }, + }); + + res.setHeader('Content-Type', 'application/json'); + res.status(200).send(JSON.stringify(openApiDocument, null, 2)); +} diff --git a/src/pages/api/v1/[...trpc].ts b/src/pages/api/v1/[...trpc].ts new file mode 100644 index 00000000..12462909 --- /dev/null +++ b/src/pages/api/v1/[...trpc].ts @@ -0,0 +1,13 @@ +import { createOpenApiNextHandler } from 'trpc-to-openapi'; +import { appRouter } from '~/server/api/root'; +import { createOpenApiContext } from '~/server/api/trpc'; + +export default createOpenApiNextHandler({ + router: appRouter, + createContext: createOpenApiContext, + onError: ({ path, error }) => { + if ('development' === process.env.NODE_ENV) { + console.error(`❌ OpenAPI failed on ${path ?? ''}: ${error.message}`); + } + }, +}); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 951725e6..031bcb33 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { userRouter } from './routers/user'; import { bankTransactionsRouter } from './routers/bankTransactions'; import { expenseRouter } from './routers/expense'; +import { openApiRouter } from './routers/openapi'; /** * This is the primary router for your server. @@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({ user: userRouter, bankTransactions: bankTransactionsRouter, expense: expenseRouter, + openApi: openApiRouter, }); // export type definition of API diff --git a/src/server/api/routers/openapi.ts b/src/server/api/routers/openapi.ts new file mode 100644 index 00000000..2c3e9d9e --- /dev/null +++ b/src/server/api/routers/openapi.ts @@ -0,0 +1,841 @@ +import { SplitType } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { simplifyDebts } from '~/lib/simplify'; +import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; +import { db } from '~/server/db'; +import { BigMath } from '~/utils/numbers'; +import { createExpense, deleteExpense, editExpense } from '../services/splitService'; + +import type { CreateExpense as CreateExpenseType } from '~/types/expense.types'; + +const userOutputSchema = z.object({ + id: z.number(), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + image: z.string().nullable().optional(), + currency: z.string(), +}); + +const MAX_API_KEY_FRIENDS_PER_PAGE = 50; + +const validateGroupMembership = async (groupId: number, userId: number, participants: number[]) => { + const userIds = [userId, ...participants]; + const groupUsers = await db.groupUser.findMany({ + where: { + groupId, + userId: { in: userIds }, + }, + select: { userId: true }, + }); + + const memberIds = new Set(groupUsers.map((gu) => gu.userId)); + + if (!memberIds.has(userId)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this group' }); + } + + for (const participantId of participants) { + if (!memberIds.has(participantId)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `User ${participantId} is not a member of this group`, + }); + } + } +}; + +const validateEditExpensePermission = async (expenseId: string, userId: number) => { + const [expenseParticipant, addedBy] = await Promise.all([ + db.expenseParticipant.findUnique({ + where: { + expenseId_userId: { + expenseId, + userId, + }, + }, + }), + db.expense.findUnique({ where: { id: expenseId }, select: { addedBy: true } }), + ]); + + if (!expenseParticipant && addedBy?.addedBy !== userId) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You are not a participant of this expense', + }); + } +}; + +const deduplicateByUserId = (participants: T[]): T[] => { + const seen = new Map(); + for (const p of participants) { + if (!seen.has(p.userId)) { + seen.set(p.userId, p); + } + } + return [...seen.values()]; +}; + +const mapRestToServiceInput = ( + input: { + expenseName: string; + amount: number; + currency: string; + expenseDate?: string; + category: string; + groupId: number | null; + paidById: number; + splitMethod: string; + participants: { userId: number; share?: number }[]; + }, + expenseId?: string, +): CreateExpenseType & { expenseId?: string } => { + const participantCount = input.participants.length; + const equalShare = + input.splitMethod === SplitType.EQUAL && participantCount > 0 + ? BigInt(input.amount) / BigInt(participantCount) + : null; + + const totalAmount = BigInt(input.amount); + + const participants = deduplicateByUserId( + input.participants.map((p) => { + const share = null !== equalShare ? equalShare : p.share !== undefined ? BigInt(p.share) : 0n; + + if (p.userId === input.paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + }), + ); + + if (null !== equalShare) { + let penniesLeft = participants.reduce((acc, p) => acc + p.amount, 0n); + const nonPayerParticipants = participants.filter( + (p) => p.userId !== input.paidById && 0n !== p.amount, + ); + + if (nonPayerParticipants.length > 0) { + let i = 0; + while (0n !== penniesLeft) { + const p = nonPayerParticipants[i % nonPayerParticipants.length]!; + p.amount -= BigMath.sign(penniesLeft); + penniesLeft -= BigMath.sign(penniesLeft); + i++; + } + } + } + + return { + ...(expenseId ? { expenseId } : {}), + name: input.expenseName, + amount: totalAmount, + currency: input.currency, + category: input.category, + groupId: input.groupId, + paidBy: input.paidById, + splitType: input.splitMethod as SplitType, + expenseDate: input.expenseDate ? new Date(input.expenseDate) : new Date(), + participants, + }; +}; + +export const openApiRouter = createTRPCRouter({ + me: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/me', + summary: 'Get current authenticated user', + tags: ['User'], + protect: true, + }, + }) + .input(z.object({})) + .output(userOutputSchema) + .query(({ ctx }) => ctx.session.user), + + getFriends: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/friends', + summary: 'List friends with balances', + tags: ['User'], + protect: true, + }, + }) + .input(z.object({})) + .output( + z.array( + z.object({ + id: z.number(), + name: z.string().nullable(), + email: z.string().nullable(), + image: z.string().nullable(), + balance: z.number(), + currency: z.string(), + }), + ), + ) + .query(async ({ ctx }) => { + const userId = ctx.session.user.id; + const hiddenIds = ctx.session.user.hiddenFriendIds; + + const participantRecords = await db.expenseParticipant.findMany({ + where: { + expense: { + expenseParticipants: { + some: { userId }, + }, + }, + userId: { not: userId }, + }, + select: { userId: true }, + distinct: ['userId'], + }); + + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + + const rawBalances = await db.balanceView.findMany({ + where: { + userId, + friendId: { notIn: hiddenIds }, + }, + include: { + group: { + select: { + simplifyDebts: true, + }, + }, + }, + }); + + for (const b of rawBalances) { + friendIdsFromExpenses.add(b.friendId); + } + + for (const hiddenId of hiddenIds) { + friendIdsFromExpenses.delete(hiddenId); + } + + if (0 === friendIdsFromExpenses.size) { + return []; + } + + const processedBalances = await Promise.all( + rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { + if (!group?.simplifyDebts || null === groupId) { + return { friendId, currency, amount }; + } + + const allGroupBalances = await db.balanceView.findMany({ + where: { groupId, currency }, + }); + + const simplified = simplifyDebts(allGroupBalances); + const simplifiedBalance = simplified.find( + (b) => b.userId === userId && b.friendId === friendId && b.currency === currency, + ); + + return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; + }), + ); + + const aggregated = processedBalances.reduce>>( + (acc, { friendId, currency, amount }) => { + if (!acc.has(friendId)) { + acc.set(friendId, new Map()); + } + const friendMap = acc.get(friendId)!; + friendMap.set(currency, (friendMap.get(currency) ?? 0n) + amount); + return acc; + }, + new Map(), + ); + + const friendIds = [...friendIdsFromExpenses]; + const users = await db.user.findMany({ + where: { id: { in: friendIds } }, + }); + const userMap = new Map(users.map((u) => [u.id, u])); + + const result = []; + for (const friendId of friendIds) { + const user = userMap.get(friendId); + if (!user) { + continue; + } + + const currencies = aggregated.get(friendId); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: user.currency, + }); + } + } + + return result; + }), + + getAllGroups: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups', + summary: 'List all groups', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({})) + .output( + z.array( + z.object({ + id: z.number(), + name: z.string(), + currency: z.string(), + memberCount: z.number(), + simplifyDebt: z.boolean(), + }), + ), + ) + .query(async ({ ctx }) => { + const groups = await db.group.findMany({ + where: { + groupUsers: { + some: { + userId: ctx.session.user.id, + }, + }, + }, + include: { + _count: { + select: { groupUsers: true }, + }, + }, + }); + + return groups.map((g) => ({ + id: g.id, + name: g.name, + currency: g.defaultCurrency ?? '', + memberCount: g._count.groupUsers, + simplifyDebt: g.simplifyDebts, + })); + }), + + getGroupDetails: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}', + summary: 'Get group with members', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({ id: z.number() })) + .output( + z.object({ + id: z.number(), + name: z.string(), + currency: z.string(), + simplifyDebt: z.boolean(), + members: z.array( + z.object({ + id: z.number(), + name: z.string().nullable(), + email: z.string().nullable(), + }), + ), + }), + ) + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + + const group = await db.group.findUnique({ + where: { id: input.id }, + include: { + groupUsers: { + include: { + user: true, + }, + }, + }, + }); + + if (!group) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); + } + + return { + id: group.id, + name: group.name, + currency: group.defaultCurrency ?? '', + simplifyDebt: group.simplifyDebts, + members: group.groupUsers.map((gu) => ({ + id: gu.user.id, + name: gu.user.name, + email: gu.user.email, + })), + }; + }), + + getGroupBalances: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}/balances', + summary: 'Get group balances', + tags: ['Group'], + protect: true, + }, + }) + .input(z.object({ id: z.number() })) + .output( + z.object({ + groupId: z.number(), + balances: z.array( + z.object({ + userId: z.number(), + userName: z.string().nullable(), + netBalance: z.number(), + }), + ), + }), + ) + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + + const [rawBalances, group] = await Promise.all([ + db.balanceView.findMany({ + where: { groupId: input.id }, + include: { user: true }, + }), + db.group.findUnique({ + where: { id: input.id }, + select: { simplifyDebts: true }, + }), + ]); + + const simplified = group?.simplifyDebts ? simplifyDebts(rawBalances) : [...rawBalances]; + + const userIds = [...new Set(simplified.map((b) => b.friendId ?? b.userId))]; + + const aggregated = userIds.map((userId) => { + const user = rawBalances.find((b) => b.userId === userId)?.user; + const balance = simplified + .filter((b) => b.userId === userId && b.friendId !== null) + .reduce((sum, b) => sum + b.amount, 0n); + + return { + userId, + userName: user?.name ?? null, + netBalance: Number(balance), + }; + }); + + return { groupId: input.id, balances: aggregated }; + }), + + getGroupExpenses: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/groups/{id}/expenses', + summary: 'List group expenses', + tags: ['Expense'], + protect: true, + }, + }) + .input( + z.object({ + id: z.number(), + since: z.string().datetime().optional(), + limit: z.number().int().min(1).max(MAX_API_KEY_FRIENDS_PER_PAGE).default(20), + offset: z.number().int().min(0).default(0), + }), + ) + .output( + z.object({ + expenses: expenseOutputSchemaArray(), + total: z.number(), + }), + ) + .query(async ({ input, ctx }) => { + await validateGroupMembership(input.id, ctx.session.user.id, []); + + const { id, since, limit, offset } = input; + + const where = { + groupId: id, + deletedBy: null, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + const [expenses, total] = await Promise.all([ + db.expense.findMany({ + where, + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + orderBy: { expenseDate: 'desc' }, + skip: offset, + take: limit, + }), + db.expense.count({ where }), + ]); + + return { expenses: expenses.map(normalizeExpense), total }; + }), + + getExpenses: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/expenses', + summary: 'List expenses across groups', + tags: ['Expense'], + protect: true, + }, + }) + .input( + z.object({ + groupId: z.number().optional(), + since: z.string().datetime().optional(), + limit: z.number().int().min(1).max(MAX_API_KEY_FRIENDS_PER_PAGE).default(20), + offset: z.number().int().min(0).default(0), + }), + ) + .output( + z.object({ + expenses: expenseOutputSchemaArray(), + total: z.number(), + }), + ) + .query(async ({ input, ctx }) => { + const { groupId, since, limit, offset } = input; + + const expenseWhere = { + deletedBy: null, + ...(groupId + ? { groupId, group: { groupUsers: { some: { userId: ctx.session.user.id } } } } + : { + expenseParticipants: { + some: { userId: ctx.session.user.id }, + }, + }), + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + const [expenses, total] = await Promise.all([ + db.expense.findMany({ + where: expenseWhere, + orderBy: { expenseDate: 'desc' }, + skip: offset, + take: limit, + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + }), + db.expense.count({ where: expenseWhere }), + ]); + + return { + expenses: expenses.map(normalizeExpense), + total, + }; + }), + + getExpenseDetails: protectedProcedure + .meta({ + openapi: { + method: 'GET', + path: '/expenses/{id}', + summary: 'Get single expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() })) + .output(expenseDetailSchema()) + .query(async ({ input, ctx }) => { + await validateEditExpensePermission(input.id, ctx.session.user.id); + + const expense = await db.expense.findUnique({ + where: { id: input.id }, + include: { + expenseParticipants: { + include: { + user: true, + }, + }, + expenseNotes: true, + addedByUser: true, + paidByUser: true, + group: true, + }, + }); + + if (!expense) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Expense not found' }); + } + + return normalizeExpenseDetail(expense); + }), + + createExpense: protectedProcedure + .meta({ + openapi: { + method: 'POST', + path: '/expenses', + summary: 'Create expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(expenseInputSchema()) + .output(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + if (null !== input.groupId) { + const allParticipantIds = input.participants.map((p) => p.userId); + await validateGroupMembership(input.groupId, ctx.session.user.id, [ + input.paidById, + ...allParticipantIds, + ]); + } + + const serviceInput = mapRestToServiceInput(input); + const { notes } = input; + + const expense = await createExpense(serviceInput, ctx.session.user.id); + + if (notes) { + await db.expenseNote.create({ + data: { + note: notes, + createdById: ctx.session.user.id, + expenseId: expense.id, + }, + }); + } + + return { id: expense.id }; + }), + + updateExpense: protectedProcedure + .meta({ + openapi: { + method: 'PUT', + path: '/expenses/{id}', + summary: 'Update expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() }).merge(expenseInputSchema())) + .output(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const { id, ...data } = input; + + await validateEditExpensePermission(id, ctx.session.user.id); + + if (null !== data.groupId) { + const allParticipantIds = data.participants.map((p) => p.userId); + await validateGroupMembership(data.groupId, ctx.session.user.id, [ + data.paidById, + ...allParticipantIds, + ]); + } + + const serviceInput = mapRestToServiceInput(data, id); + const { notes } = data; + + await editExpense(serviceInput, ctx.session.user.id); + + if (notes !== undefined) { + await db.expenseNote.deleteMany({ where: { expenseId: id } }); + if (notes) { + await db.expenseNote.create({ + data: { + note: notes, + createdById: ctx.session.user.id, + expenseId: id, + }, + }); + } + } + + return { id }; + }), + + deleteExpense: protectedProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/expenses/{id}', + summary: 'Delete expense', + tags: ['Expense'], + protect: true, + }, + }) + .input(z.object({ id: z.string() })) + .output(z.object({})) + .mutation(async ({ input, ctx }) => { + await validateEditExpensePermission(input.id, ctx.session.user.id); + await deleteExpense(input.id, ctx.session.user.id); + + return {}; + }), +}); + +function expenseOutputSchemaArray() { + return z.array(expenseOutputSchema()); +} + +function expenseOutputSchema() { + return z.object({ + id: z.string(), + expenseName: z.string(), + amount: z.number(), + currency: z.string(), + expenseDate: z.string().datetime(), + category: z.string(), + paidBy: z.object({ + id: z.number(), + name: z.string().nullable(), + }), + splitMethod: z.string(), + participants: z.array( + z.object({ + userId: z.number(), + share: z.number().nullable(), + }), + ), + groupId: z.number().nullable(), + isReimbursement: z.boolean(), + }); +} + +function expenseDetailSchema() { + return expenseOutputSchema().extend({ + notes: z.string().nullable(), + addedBy: z.object({ + id: z.number(), + name: z.string().nullable(), + }), + deletedAt: z.string().datetime().nullable(), + }); +} + +function expenseInputSchema() { + return z.object({ + expenseName: z.string(), + amount: z.number().int(), + currency: z.string(), + expenseDate: z.string().datetime().optional(), + category: z.string(), + notes: z.string().optional(), + groupId: z.number().nullable(), + paidById: z.number(), + splitMethod: z.enum([ + SplitType.ADJUSTMENT, + SplitType.EQUAL, + SplitType.PERCENTAGE, + SplitType.SHARE, + SplitType.EXACT, + SplitType.SETTLEMENT, + ]), + participants: z.array( + z.object({ + userId: z.number(), + share: z.number().int().optional(), + }), + ), + }); +} + +function normalizeExpense(expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; +}) { + return { + id: expense.id, + expenseName: expense.name, + amount: Number(expense.amount), + currency: expense.currency, + expenseDate: expense.expenseDate.toISOString(), + category: expense.category, + paidBy: { + id: expense.paidByUser?.id ?? 0, + name: expense.paidByUser?.name ?? null, + }, + splitMethod: expense.splitType, + participants: expense.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: expense.groupId, + isReimbursement: SplitType.SETTLEMENT === expense.splitType, + }; +} + +function normalizeExpenseDetail(expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + deletedAt: Date | null; + expenseNotes: { note: string }[]; + paidByUser: { id: number; name: string | null } | null; + addedByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; +}) { + return { + ...normalizeExpense(expense), + notes: expense.expenseNotes[0]?.note ?? null, + addedBy: { + id: expense.addedByUser?.id ?? 0, + name: expense.addedByUser?.name ?? null, + }, + deletedAt: expense.deletedAt?.toISOString() ?? null, + }; +} + +export type OpenApiRouter = typeof openApiRouter; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87..c700de3c 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -1,3 +1,5 @@ +import { createHash } from 'crypto'; + import { TRPCError } from '@trpc/server'; import { type User } from 'next-auth'; import { z } from 'zod'; @@ -419,6 +421,68 @@ export const userRouter = createTRPCRouter({ }), getWebPushPublicKey: protectedProcedure.query(() => env.WEB_PUSH_PUBLIC_KEY ?? ''), + + getApiKeys: protectedProcedure.query(async ({ ctx }) => { + const keys = await db.apiKey.findMany({ + where: { userId: ctx.session.user.id }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + name: true, + createdAt: true, + lastUsedAt: true, + }, + }); + + return keys; + }), + + createApiKey: protectedProcedure + .input(z.object({ name: z.string().min(1) })) + .mutation(async ({ input, ctx }) => { + const existingCount = await db.apiKey.count({ + where: { userId: ctx.session.user.id }, + }); + + if (existingCount >= 10) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Maximum of 10 API keys per user', + }); + } + + const rawKey = `sp_${crypto.randomUUID().replace(/-/g, '')}`; + const keyHash = createHash('sha256').update(rawKey).digest('hex'); + + await db.apiKey.create({ + data: { + userId: ctx.session.user.id, + name: input.name, + keyHash, + }, + }); + + return { key: rawKey }; + }), + + deleteApiKey: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input, ctx }) => { + const key = await db.apiKey.findFirst({ + where: { + id: input.id, + userId: ctx.session.user.id, + }, + }); + + if (!key) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + await db.apiKey.delete({ where: { id: input.id } }); + + return true; + }), }); export const getUserMap = async (userIds: number[]) => { diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1..64de34b5 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -12,6 +12,8 @@ import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; +import { OpenApiMeta } from 'trpc-to-openapi'; +import { createHash } from 'crypto'; import { getServerAuthSession } from '~/server/auth'; import { db } from '~/server/db'; @@ -38,7 +40,7 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => ({ +export const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, db, }); @@ -60,6 +62,55 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { }); }; +/** + * Creates context for OpenAPI REST requests. Authenticates via X-API-Key header. + */ +export const createOpenApiContext = async (opts: CreateNextContextOptions & { info?: unknown }) => { + const { req, res } = opts; + + const apiKey = req.headers['x-api-key']; + if (apiKey && 'string' === typeof apiKey) { + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + const keyRecord = await db.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (keyRecord) { + const shouldUpdateLastUsed = + !keyRecord.lastUsedAt || + new Date().getTime() - keyRecord.lastUsedAt.getTime() > 5 * 60 * 1000; + + if (shouldUpdateLastUsed) { + await db.apiKey.update({ + where: { id: keyRecord.id }, + data: { lastUsedAt: new Date() }, + }); + } + + return createInnerTRPCContext({ + session: { + user: { + id: keyRecord.user.id, + name: keyRecord.user.name, + email: keyRecord.user.email, + image: keyRecord.user.image, + currency: keyRecord.user.currency, + defaultCurrency: keyRecord.user.defaultCurrency, + obapiProviderId: keyRecord.user.obapiProviderId, + bankingId: keyRecord.user.bankingId, + preferredLanguage: keyRecord.user.preferredLanguage, + hiddenFriendIds: keyRecord.user.hiddenFriendIds, + }, + expires: new Date(Date.now() + 86400000).toISOString(), + } as Session, + }); + } + } + + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid or missing API key' }); +}; + /** * 2. INITIALIZATION * @@ -68,18 +119,21 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { * errors on the backend. */ -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }; - }, -}); +const t = initTRPC + .context() + .meta() + .create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, + }); /** * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) @@ -94,6 +148,7 @@ const t = initTRPC.context().create({ * @see https://trpc.io/docs/router */ export const createTRPCRouter = t.router; +export const createCallerFactory = t.createCallerFactory; /** * Public (unauthenticated) procedure diff --git a/src/tests/openapi.test.ts b/src/tests/openapi.test.ts new file mode 100644 index 00000000..6c167297 --- /dev/null +++ b/src/tests/openapi.test.ts @@ -0,0 +1,981 @@ +import { createHash } from 'crypto'; +import { SplitType } from '@prisma/client'; + +import { simplifyDebts } from '~/lib/simplify'; + +jest.mock('~/server/db', () => ({ + db: { + apiKey: { + findUnique: jest.fn(), + update: jest.fn(), + }, + expenseParticipant: { + findUnique: jest.fn(), + }, + expense: { + findUnique: jest.fn(), + }, + groupUser: { + findMany: jest.fn(), + }, + }, +})); + +jest.mock('~/server/api/trpc', () => ({ + createTRPCRouter: jest.fn(), + protectedProcedure: { use: jest.fn() }, + publicProcedure: { use: jest.fn() }, + createCallerFactory: jest.fn(), + createInnerTRPCContext: jest.fn(), + createTRPCContext: jest.fn(), + createOpenApiContext: jest.fn(), + groupProcedure: { input: jest.fn(), use: jest.fn() }, +})); + +describe('API key hash', () => { + it('should produce consistent SHA-256 hashes', () => { + const key = 'sp_test_key_123'; + const hash1 = createHash('sha256').update(key).digest('hex'); + const hash2 = createHash('sha256').update(key).digest('hex'); + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); + }); + + it('should produce different hashes for different keys', () => { + const hash1 = createHash('sha256').update('sp_key_one').digest('hex'); + const hash2 = createHash('sha256').update('sp_key_two').digest('hex'); + expect(hash1).not.toBe(hash2); + }); +}); + +describe('simplifyDebts', () => { + it('should simplify group balances to zero in a 3-way cycle', () => { + const balances = [ + { + userId: 1, + friendId: 2, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + userId: 2, + friendId: 3, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + userId: 3, + friendId: 1, + groupId: 1, + currency: 'USD', + amount: 100n, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const simplified = simplifyDebts(balances); + const total = simplified.reduce((sum, b) => sum + b.amount, 0n); + expect(total).toBe(0n); + }); + + it('should preserve a simple direct debt', () => { + const balances = [ + { + userId: 1, + friendId: 2, + groupId: null, + currency: 'USD', + amount: 5000n, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + const simplified = simplifyDebts(balances); + expect(simplified.length).toBeGreaterThanOrEqual(1); + expect(simplified.some((b) => b.userId === 1 && b.friendId === 2 && b.amount === 5000n)).toBe( + true, + ); + }); +}); + +describe('normalizeExpense', () => { + const expense: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + } = { + id: 'exp-1', + name: 'Test Expense', + amount: 1234n, + currency: 'USD', + expenseDate: new Date('2025-01-15'), + category: 'Food', + splitType: SplitType.EQUAL, + groupId: null as number | null, + paidByUser: { id: 1, name: 'Alice' } as { id: number; name: string | null }, + expenseParticipants: [ + { userId: 1, amount: 617n }, + { userId: 2, amount: 617n }, + ], + }; + + const normalize = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + id: e.id, + expenseName: e.name, + amount: Number(e.amount), + currency: e.currency, + expenseDate: e.expenseDate.toISOString(), + category: e.category, + paidBy: { + id: e.paidByUser?.id ?? 0, + name: e.paidByUser?.name ?? null, + }, + splitMethod: e.splitType, + participants: e.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: e.groupId, + isReimbursement: SplitType.SETTLEMENT === e.splitType, + }); + + it('should convert bigint amounts to numbers', () => { + const result = normalize(expense); + expect(result.amount).toBe(1234); + expect(result.participants[0]?.share).toBe(617); + }); + + it('should format dates as ISO 8601 strings', () => { + const result = normalize(expense); + expect(result.expenseDate).toBe('2025-01-15T00:00:00.000Z'); + }); + + it('should mark settlement expenses as reimbursements', () => { + const settlement = { ...expense, splitType: SplitType.SETTLEMENT }; + const result = normalize(settlement); + expect(result.isReimbursement).toBe(true); + }); + + it('should not mark EQUAL expenses as reimbursements', () => { + const result = normalize(expense); + expect(result.isReimbursement).toBe(false); + }); + + it('should handle null paidByUser gracefully', () => { + const unlinked = { + ...expense, + paidByUser: null, + }; + const result = normalize(unlinked); + expect(result.paidBy.id).toBe(0); + expect(result.paidBy.name).toBeNull(); + }); +}); + +describe('REST input to service input mapping', () => { + it('should map expenseName to name and paidById to paidBy', () => { + const rest = { + expenseName: 'Coffee', + amount: 500, + currency: 'USD', + category: 'Food', + groupId: null as number | null, + paidById: 1, + splitMethod: SplitType.EQUAL, + participants: [{ userId: 1 }, { userId: 2 }], + }; + + expect(rest.expenseName).toBe('Coffee'); + expect(BigInt(rest.amount)).toBe(500n); + expect(rest.paidById).toBe(1); + }); + + it('should calculate equal shares for EQUAL split type', () => { + const participantCount = 4; + const amount = 1000n; + const equalShare = amount / BigInt(participantCount); + + expect(equalShare).toBe(250n); + }); + + it('should integer-divide amount for equal splits', () => { + const participantCount = 3; + const amount = 1000n; + const share = amount / BigInt(participantCount); + + expect(share).toBe(333n); + }); + + it('should apply sign convention for EQUAL split: payer gets positive, non-payers negative', () => { + const amount = 10; + const paidBy = 150; + const participants = [{ userId: 150 }, { userId: 151 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + const result = participants.map((p) => { + if (p.userId === paidBy) { + return { userId: p.userId, amount: -equalShare + totalAmount }; + } + return { userId: p.userId, amount: -equalShare }; + }); + + expect(result[0]?.amount).toBe(5n); + expect(result[1]?.amount).toBe(-5n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should handle penny remainder for EQUAL split with uneven division', () => { + const amount = 10; + const paidBy = 1; + const participants = [{ userId: 1 }, { userId: 2 }, { userId: 3 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + const result = participants.map((p) => { + if (p.userId === paidBy) { + return { userId: p.userId, amount: -equalShare + totalAmount }; + } + return { userId: p.userId, amount: -equalShare }; + }); + + let penniesLeft = result.reduce((acc, p) => acc + p.amount, 0n); + const nonPayerParticipants = result.filter((p) => p.userId !== paidBy && 0n !== p.amount); + const sign = (x: bigint) => (0n === x ? 0n : 0n > x ? -1n : 1n); + let i = 0; + while (0n !== penniesLeft) { + const p = nonPayerParticipants[i % nonPayerParticipants.length]!; + p.amount -= sign(penniesLeft); + penniesLeft -= sign(penniesLeft); + i++; + } + + expect(result[0]?.amount).toBe(7n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should apply sign convention for non-EQUAL splits too', () => { + const totalAmount = 1000n; + const paidById = 1; + const participants = [ + { userId: 1, share: 500 }, + { userId: 2, share: 500 }, + ]; + + const result = participants.map((p) => { + const share = p.share !== undefined ? BigInt(p.share) : 0n; + if (p.userId === paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + }); + + expect(result[0]?.amount).toBe(500n); + expect(result[1]?.amount).toBe(-500n); + expect(result.reduce((acc, p) => acc + p.amount, 0n)).toBe(0n); + }); + + it('should default missing share to 0 for non-EQUAL splits', () => { + const totalAmount = 500n; + const paidById = 1; + const participants = [{ userId: 1 }, { userId: 2, share: 500 }]; + + const result = participants.map((p) => { + const share = p.share !== undefined ? BigInt(p.share) : 0n; + if (p.userId === paidById) { + return { userId: p.userId, amount: -share + totalAmount }; + } + return { userId: p.userId, amount: -share }; + }); + + expect(result[0]?.amount).toBe(500n); + expect(result[1]?.amount).toBe(-500n); + }); + + it('should compute 0 as share for EQUAL split with single participant', () => { + const amount = 1000; + const participants = [{ userId: 1 }]; + + const totalAmount = BigInt(amount); + const equalShare = totalAmount / BigInt(participants.length); + + expect(equalShare).toBe(1000n); + + const result = participants.map((p) => ({ + userId: p.userId, + amount: -equalShare + totalAmount, + })); + + expect(result[0]?.amount).toBe(0n); + }); +}); + +describe('validateEditExpensePermission logic', () => { + describe('addedBy check condition: !participant && addedBy?.addedBy !== userId', () => { + it('should not throw when user is a participant (short-circuit)', () => { + const participant = { userId: 1 }; + const addedBy = { addedBy: 2 }; + const userId = 1; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(false); + }); + + it('should not throw when user is the creator (addedBy matches userId)', () => { + const participant = null; + const addedBy = { addedBy: 2 }; + const userId = 2; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(false); + }); + + it('should throw when user is neither participant nor creator', () => { + const participant = null; + const addedBy = { addedBy: 2 }; + const userId = 3; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(true); + }); + + it('should throw when addedBy is null and user is not a participant', () => { + const participant = null; + const addedBy = null as { addedBy: number } | null; + const userId = 3; + + const shouldThrow = !participant && addedBy?.addedBy !== userId; + expect(shouldThrow).toBe(true); + }); + }); +}); + +describe('group membership validation logic', () => { + it('should allow when user is in the group member set', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 1; + + expect(memberIds.has(userId)).toBe(true); + }); + + it('should deny when user is not in the group member set', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 4; + + expect(memberIds.has(userId)).toBe(false); + }); + + it('should ensure all participants are group members', () => { + const memberIds = new Set([1, 2, 3]); + const participantIds = [1, 2, 3]; + + const allMembers = participantIds.every((id) => memberIds.has(id)); + expect(allMembers).toBe(true); + }); + + it('should detect when a participant is not a group member', () => { + const memberIds = new Set([1, 2]); + const participantIds = [1, 2, 3]; + + const allMembers = participantIds.every((id) => memberIds.has(id)); + expect(allMembers).toBe(false); + }); + + it('should include userId 0 in participants list', () => { + const userId = 10; + const participants = [0, 1, 2]; + const userIds = [userId, ...participants]; + + expect(userIds).toContain(0); + expect(userIds).toHaveLength(4); + expect(userIds[0]).toBe(10); + expect(userIds[1]).toBe(0); + }); + + it('should include paidById even when already in participants', () => { + const userId = 1; + const participants = [1, 2, 3]; + const userIds = [userId, ...participants]; + + expect(userIds).toEqual([1, 1, 2, 3]); + expect(userIds.filter((id) => id === 1)).toHaveLength(2); + }); +}); + +describe('normalizeExpenseDetail', () => { + const normalizeDetail = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + deletedAt: Date | null; + expenseNotes: { note: string }[]; + paidByUser: { id: number; name: string | null } | null; + addedByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + ...normalize({ + id: e.id, + name: e.name, + amount: e.amount, + currency: e.currency, + expenseDate: e.expenseDate, + category: e.category, + splitType: e.splitType, + groupId: e.groupId, + paidByUser: e.paidByUser, + expenseParticipants: e.expenseParticipants, + }), + notes: e.expenseNotes[0]?.note ?? null, + addedBy: { + id: e.addedByUser?.id ?? 0, + name: e.addedByUser?.name ?? null, + }, + deletedAt: e.deletedAt?.toISOString() ?? null, + }); + + const normalize = (e: { + id: string; + name: string; + amount: bigint; + currency: string; + expenseDate: Date; + category: string; + splitType: SplitType; + groupId: number | null; + paidByUser: { id: number; name: string | null } | null; + expenseParticipants: { userId: number; amount: bigint }[]; + }) => ({ + id: e.id, + expenseName: e.name, + amount: Number(e.amount), + currency: e.currency, + expenseDate: e.expenseDate.toISOString(), + category: e.category, + paidBy: { + id: e.paidByUser?.id ?? 0, + name: e.paidByUser?.name ?? null, + }, + splitMethod: e.splitType, + participants: e.expenseParticipants.map((ep) => ({ + userId: ep.userId, + share: Number(ep.amount), + })), + groupId: e.groupId, + isReimbursement: SplitType.SETTLEMENT === e.splitType, + }); + + const baseExpense = { + id: 'exp-detail-1', + name: 'Detail Expense', + amount: 5000n, + currency: 'EUR', + expenseDate: new Date('2025-06-01'), + category: 'Travel', + splitType: SplitType.EXACT, + groupId: null as number | null, + deletedAt: null as Date | null, + paidByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, + addedByUser: { id: 10, name: 'Bob' } as { id: number; name: string | null }, + expenseParticipants: [{ userId: 10, amount: 5000n }], + }; + + it('should include notes from the first expense note', () => { + const expense = { ...baseExpense, expenseNotes: [{ note: 'Hello world' }] }; + const result = normalizeDetail(expense); + expect(result.notes).toBe('Hello world'); + }); + + it('should return null for notes when no notes exist', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.notes).toBeNull(); + }); + + it('should include addedBy from the creator', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.addedBy.id).toBe(10); + expect(result.addedBy.name).toBe('Bob'); + }); + + it('should handle null addedByUser gracefully', () => { + const expense = { ...baseExpense, expenseNotes: [], addedByUser: null }; + const result = normalizeDetail(expense); + expect(result.addedBy.id).toBe(0); + expect(result.addedBy.name).toBeNull(); + }); + + it('should return null for deletedAt when expense is active', () => { + const expense = { ...baseExpense, expenseNotes: [] }; + const result = normalizeDetail(expense); + expect(result.deletedAt).toBeNull(); + }); + + it('should return ISO string for deletedAt when expense is deleted', () => { + const deletedDate = new Date('2025-01-15T12:00:00.000Z'); + const expense = { ...baseExpense, expenseNotes: [], deletedAt: deletedDate }; + const result = normalizeDetail(expense); + expect(result.deletedAt).toBe('2025-01-15T12:00:00.000Z'); + }); +}); + +describe('deduplicateByUserId', () => { + const deduplicateByUserId = (participants: T[]): T[] => { + const seen = new Map(); + for (const p of participants) { + if (!seen.has(p.userId)) { + seen.set(p.userId, p); + } + } + return [...seen.values()]; + }; + + it('should keep only one entry per userId', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 2, amount: 30 }, + { userId: 1, amount: 20 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(2); + }); + + it('should keep the first occurrence when duplicates exist', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 1, amount: 20 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result[0]?.amount).toBe(50); + }); + + it('should return unchanged array when no duplicates', () => { + const participants = [ + { userId: 1, amount: 50 }, + { userId: 2, amount: 30 }, + ]; + + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(2); + expect(result[0]?.userId).toBe(1); + expect(result[1]?.userId).toBe(2); + }); + + it('should return empty for empty input', () => { + const participants: { userId: number; amount: number }[] = []; + const result = deduplicateByUserId(participants); + expect(result).toHaveLength(0); + }); +}); + +describe('GET /expenses query logic', () => { + it('should filter out deleted expenses with deletedBy: null', () => { + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { + some: { userId }, + }, + }; + + expect(expenseWhere.deletedBy).toBeNull(); + expect(expenseWhere.expenseParticipants).toBeDefined(); + }); + + it('should filter by groupId when provided', () => { + const groupId = 151; + const userId = 150; + const expenseWhere = { + deletedBy: null, + groupId, + group: { groupUsers: { some: { userId } } }, + }; + + expect(expenseWhere.groupId).toBe(151); + expect(expenseWhere.deletedBy).toBeNull(); + }); + + it('should filter by user participation when no groupId', () => { + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { + some: { userId }, + }, + }; + + const hasParticipationFilter = + 'expenseParticipants' in expenseWhere && expenseWhere.expenseParticipants !== undefined; + expect(hasParticipationFilter).toBe(true); + }); + + it('should add date filter when since is provided', () => { + const since = '2026-01-01T00:00:00.000Z'; + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { some: { userId } }, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(expenseWhere.expenseDate).toBeDefined(); + const expenseDate = expenseWhere.expenseDate as Record | undefined; + expect(expenseDate?.gte).toBeInstanceOf(Date); + }); + + it('should not have date filter when since is not provided', () => { + const since = undefined; + const userId = 150; + const expenseWhere = { + deletedBy: null, + expenseParticipants: { some: { userId } }, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(expenseWhere).not.toHaveProperty('expenseDate'); + }); +}); + +describe('GET /groups/{id}/expenses query logic', () => { + it('should filter by groupId and deletedBy: null', () => { + const groupId = 151; + const where = { + groupId, + deletedBy: null, + }; + + expect(where.groupId).toBe(151); + expect(where.deletedBy).toBeNull(); + }); + + it('should add date filter when since is provided', () => { + const groupId = 151; + const since = '2026-01-01T00:00:00.000Z'; + const where = { + groupId, + deletedBy: null, + ...(since ? { expenseDate: { gte: new Date(since) } } : {}), + }; + + expect(where.expenseDate).toBeDefined(); + }); +}); + +describe('GET /expenses query uses Expense.findMany (not ExpenseParticipant.findMany)', () => { + it('should query Expense with direct includes (not nested expense wrapper)', () => { + const queryConfig = { + model: 'expense', + include: { + expenseParticipants: true, + paidByUser: true, + group: true, + deletedByUser: true, + }, + }; + + expect(queryConfig.model).toBe('expense'); + expect(queryConfig.include).toHaveProperty('expenseParticipants'); + expect(queryConfig.include).not.toHaveProperty('expense'); + }); +}); + +describe('DELETE /expenses/{id} mutation logic', () => { + it('should return an empty object on success', () => { + const deleteExpenseMutation = async () => ({}); + const result = deleteExpenseMutation(); + + expect(result).resolves.toEqual({}); + }); + + it('should validate expense permission before deleting', async () => { + let permissionChecked = false; + let deleted = false; + + const validatePermission = () => { + permissionChecked = true; + }; + const deleteExpense = () => { + deleted = true; + return Promise.resolve(); + }; + + validatePermission(); + await deleteExpense(); + + expect(permissionChecked).toBe(true); + expect(deleted).toBe(true); + }); +}); + +describe('GET /me endpoint logic', () => { + it('should return the session user directly', () => { + const session = { + user: { + id: 150, + name: 'test', + email: 'test@example.com', + image: null, + currency: 'USD', + }, + }; + + const result = session.user; + expect(result.id).toBe(150); + expect(result.name).toBe('test'); + expect(result.currency).toBe('USD'); + }); +}); + +describe('GET /friends endpoint logic', () => { + it('should include friends with zero balance from shared expenses', () => { + const userId = 150; + const friendId = 151; + + const participantRecords = [{ userId: friendId }]; + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + + const rawBalances: { friendId: number; currency: string; amount: bigint }[] = []; + for (const b of rawBalances) { + friendIdsFromExpenses.add(b.friendId); + } + + const aggregated = new Map>(); + + const users = [{ id: friendId, name: 'test2', email: 'test2@test.com', image: null }]; + const userMap = new Map(users.map((u) => [u.id, u])); + + const result = []; + for (const fid of friendIdsFromExpenses) { + const user = userMap.get(fid); + if (!user) { + continue; + } + + const currencies = aggregated.get(fid); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: 'USD', + }); + } + } + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(friendId); + expect(result[0]?.balance).toBe(0); + }); + + it('should still show per-currency entries for non-zero balances', () => { + const friendId = 151; + + const friendIdsFromExpenses = new Set([friendId]); + const aggregated = new Map>(); + aggregated.set( + friendId, + new Map([ + ['USD', 500n], + ['GBP', -200n], + ]), + ); + + const users = [{ id: friendId, name: 'test2', email: 'test2@test.com', image: null }]; + const userMap = new Map(users.map((u) => [u.id, u])); + + const result = []; + for (const fid of friendIdsFromExpenses) { + const user = userMap.get(fid); + if (!user) { + continue; + } + + const currencies = aggregated.get(fid); + if (currencies && 0 < currencies.size) { + for (const [currency, amount] of currencies) { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: Number(amount), + currency, + }); + } + } else { + result.push({ + id: user.id, + name: user.name, + email: user.email, + image: user.image, + balance: 0, + currency: 'USD', + }); + } + } + + expect(result).toHaveLength(2); + expect(result[0]?.currency).toBe('USD'); + expect(result[0]?.balance).toBe(500); + expect(result[1]?.currency).toBe('GBP'); + expect(result[1]?.balance).toBe(-200); + }); + + it('should filter out hidden friends', () => { + const hiddenIds = [151]; + const participantRecords = [{ userId: 151 }, { userId: 152 }]; + const friendIdsFromExpenses = new Set(participantRecords.map((p) => p.userId)); + + for (const hiddenId of hiddenIds) { + friendIdsFromExpenses.delete(hiddenId); + } + + expect(friendIdsFromExpenses.has(151)).toBe(false); + expect(friendIdsFromExpenses.has(152)).toBe(true); + }); +}); + +describe('GET /groups endpoint logic', () => { + it('should query groups where user is a member', () => { + const userId = 150; + const queryConfig = { + where: { + groupUsers: { + some: { userId }, + }, + }, + include: { + _count: { + select: { groupUsers: true }, + }, + }, + }; + + expect(queryConfig.where.groupUsers.some.userId).toBe(userId); + expect(queryConfig.include._count.select).toHaveProperty('groupUsers'); + }); +}); + +describe('GET /groups/{id} endpoint logic', () => { + it('should query group with user includes', () => { + const groupId = 151; + const queryConfig = { + where: { id: groupId }, + include: { + groupUsers: { + include: { + user: { + select: { id: true, name: true, email: true }, + }, + }, + }, + }, + }; + + expect(queryConfig.where.id).toBe(groupId); + expect(queryConfig.include.groupUsers).toBeDefined(); + }); + + it('should validate group membership before returning details', () => { + const memberIds = new Set([1, 2, 3]); + const userId = 1; + const participants: number[] = []; + + const userIds = [userId, ...participants]; + const allMembers = userIds.every((id) => memberIds.has(id)); + + expect(allMembers).toBe(true); + expect(userIds).toEqual([1]); + }); +}); + +describe('PUT /expenses/{id} notes logic', () => { + it('should clear existing notes when empty string is sent', () => { + const notes = ''; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(true); + + let noteExists = true; + if (notes !== undefined) { + noteExists = false; + if (notes) { + noteExists = true; + } + } + + expect(noteExists).toBe(false); + }); + + it('should replace notes when non-empty string is sent', () => { + const notes = 'updated note'; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(true); + + let noteExists = false; + if (notes !== undefined) { + noteExists = false; + if (notes) { + noteExists = true; + } + } + + expect(noteExists).toBe(true); + }); + + it('should leave notes untouched when notes field is not sent', () => { + const notes = undefined; + const shouldDelete = notes !== undefined; + + expect(shouldDelete).toBe(false); + }); +});