diff --git a/.env.default b/.env.default index 175ab97a0..d171c3cad 100644 --- a/.env.default +++ b/.env.default @@ -30,6 +30,11 @@ LOG_MAX_FILES=365 NEXTAUTH_URL="http://localhost:80" NEXTAUTH_SECRET=cake_is_love_cake_is_life +# Stripe +STRIPE_SECRET_KEY=sk_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_... +STRIPE_WEBHOOK_SECRET=whsec_... + # Password Hashing and Encryption PASSWORD_SALT_ROUNDS="12" PASSWORD_ENCRYPTION_KEY="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # Must be 256 bits (43 characters) long diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 3df1b4093..8575e45fd 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -25,7 +25,9 @@ services: API_KEY_ENCRYPTION_KEY: ${API_KEY_ENCRYPTION_KEY} FEIDE_CLIENT_ID: ${FEIDE_CLIENT_ID} FEIDE_CLIENT_SECRET: ${FEIDE_CLIENT_SECRET} - MAIL_SERVER: ${MAIL_SERVER} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} MAIL_DOMAIN: ${MAIL_DOMAIN} DOMAIN: ${DOMAIN} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} diff --git a/jest.config.ts b/jest.config.ts index 76f411b45..bcad9daf7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,6 +14,11 @@ const config: Config = { // This is needed becaue jest doesn't handle the this code is inside node_modules '^@/prisma-dobbel-omega/(.*)$': '/node_modules/.prisma-dobbel-omega/$1', }, + globals: { + 'ts-jest': { + useESM: true, + }, + } } export default async function jestConfig() { diff --git a/package-lock.json b/package-lock.json index 1a76315cb..6a982c676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@prisma/client": "^6.1.0", "@react-email/components": "^0.0.31", "@react-email/render": "^1.0.3", + "@stripe/react-stripe-js": "^3.3.0", + "@stripe/stripe-js": "^5.9.2", "bcrypt": "^5.1.1", "html5-qrcode": "^2.3.8", "jsonwebtoken": "^9.0.2", @@ -37,6 +39,7 @@ "sass": "^1.83.0", "server-only": "^0.0.1", "sharp": "^0.33.5", + "stripe": "^17.7.0", "unified": "^11.0.5", "uuid": "^10.0.0", "winston": "^3.17.0", @@ -159,16 +162,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", - "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -264,6 +267,16 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -410,13 +423,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", - "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -733,14 +746,14 @@ } }, "node_modules/@babel/template": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", - "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.1", + "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" }, "engines": { @@ -748,38 +761,28 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", - "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/template": "^7.27.1", - "@babel/types": "^7.27.1", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", - "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -844,9 +847,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", "license": "MIT", "optional": true, "dependencies": { @@ -854,9 +857,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", "cpu": [ "ppc64" ], @@ -871,9 +874,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", "cpu": [ "arm" ], @@ -888,9 +891,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", "cpu": [ "arm64" ], @@ -905,9 +908,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", "cpu": [ "x64" ], @@ -922,9 +925,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", "cpu": [ "arm64" ], @@ -939,9 +942,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", "cpu": [ "x64" ], @@ -956,9 +959,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", "cpu": [ "arm64" ], @@ -973,9 +976,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", "cpu": [ "x64" ], @@ -990,9 +993,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", "cpu": [ "arm" ], @@ -1007,9 +1010,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", "cpu": [ "arm64" ], @@ -1024,9 +1027,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", "cpu": [ "ia32" ], @@ -1041,9 +1044,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", "cpu": [ "loong64" ], @@ -1058,9 +1061,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", "cpu": [ "mips64el" ], @@ -1075,9 +1078,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", "cpu": [ "ppc64" ], @@ -1092,9 +1095,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", "cpu": [ "riscv64" ], @@ -1109,9 +1112,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", "cpu": [ "s390x" ], @@ -1126,9 +1129,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", "cpu": [ "x64" ], @@ -1143,9 +1146,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", "cpu": [ "arm64" ], @@ -1160,9 +1163,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", "cpu": [ "x64" ], @@ -1177,9 +1180,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", "cpu": [ "arm64" ], @@ -1194,9 +1197,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", "cpu": [ "x64" ], @@ -1211,9 +1214,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", "cpu": [ "x64" ], @@ -1228,9 +1231,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", "cpu": [ "arm64" ], @@ -1245,9 +1248,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", "cpu": [ "ia32" ], @@ -1262,9 +1265,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", "cpu": [ "x64" ], @@ -1422,139 +1425,6 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", @@ -1570,21 +1440,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", @@ -1600,69 +1455,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", @@ -1684,27 +1476,6 @@ "@img/sharp-libvips-linux-x64": "1.0.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", @@ -1726,60 +1497,6 @@ "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2315,18 +2032,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2339,16 +2052,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -2357,9 +2060,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2634,232 +2337,42 @@ "optional": true, "dependencies": { "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.4.1", - "@parcel/watcher-darwin-arm64": "2.4.1", - "@parcel/watcher-darwin-x64": "2.4.1", - "@parcel/watcher-freebsd-x64": "2.4.1", - "@parcel/watcher-linux-arm-glibc": "2.4.1", - "@parcel/watcher-linux-arm64-glibc": "2.4.1", - "@parcel/watcher-linux-arm64-musl": "2.4.1", - "@parcel/watcher-linux-x64-glibc": "2.4.1", - "@parcel/watcher-linux-x64-musl": "2.4.1", - "@parcel/watcher-win32-arm64": "2.4.1", - "@parcel/watcher-win32-ia32": "2.4.1", - "@parcel/watcher-win32-x64": "2.4.1" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz", - "integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz", - "integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz", - "integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz", - "integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz", - "integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz", - "integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz", - "integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz", - "integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz", - "integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz", - "integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, "engines": { "node": ">= 10.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.4.1", + "@parcel/watcher-darwin-arm64": "2.4.1", + "@parcel/watcher-darwin-x64": "2.4.1", + "@parcel/watcher-freebsd-x64": "2.4.1", + "@parcel/watcher-linux-arm-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-glibc": "2.4.1", + "@parcel/watcher-linux-arm64-musl": "2.4.1", + "@parcel/watcher-linux-x64-glibc": "2.4.1", + "@parcel/watcher-linux-x64-musl": "2.4.1", + "@parcel/watcher-win32-arm64": "2.4.1", + "@parcel/watcher-win32-ia32": "2.4.1", + "@parcel/watcher-win32-x64": "2.4.1" } }, - "node_modules/@parcel/watcher-win32-ia32": { + "node_modules/@parcel/watcher-linux-x64-glibc": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", - "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz", + "integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==", "cpu": [ - "ia32" + "x64" ], "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">= 10.0.0" @@ -2869,16 +2382,16 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-win32-x64": { + "node_modules/@parcel/watcher-linux-x64-musl": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz", - "integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz", + "integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==", "cpu": [ "x64" ], "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">= 10.0.0" @@ -3311,6 +2824,29 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.6.0.tgz", + "integrity": "sha512-zEnaUmTOsu7zhl3RWbZ0l1dRiad+QIbcAYzQfF+yYelURJowhAwesRHKWH+qGAIBEpkO6/VCLFHhVLH9DtPlnw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz", + "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -3573,7 +3109,6 @@ "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", - "dev": true, "dependencies": { "undici-types": "~6.20.0" } @@ -4511,6 +4046,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5224,6 +4788,20 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5251,22 +4829,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.93", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.93.tgz", @@ -5400,13 +4962,10 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -5415,7 +4974,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -5466,10 +5024,10 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -5518,9 +5076,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", "devOptional": true, "hasInstallScript": true, "license": "MIT", @@ -5531,31 +5089,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.3", + "@esbuild/android-arm": "0.25.3", + "@esbuild/android-arm64": "0.25.3", + "@esbuild/android-x64": "0.25.3", + "@esbuild/darwin-arm64": "0.25.3", + "@esbuild/darwin-x64": "0.25.3", + "@esbuild/freebsd-arm64": "0.25.3", + "@esbuild/freebsd-x64": "0.25.3", + "@esbuild/linux-arm": "0.25.3", + "@esbuild/linux-arm64": "0.25.3", + "@esbuild/linux-ia32": "0.25.3", + "@esbuild/linux-loong64": "0.25.3", + "@esbuild/linux-mips64el": "0.25.3", + "@esbuild/linux-ppc64": "0.25.3", + "@esbuild/linux-riscv64": "0.25.3", + "@esbuild/linux-s390x": "0.25.3", + "@esbuild/linux-x64": "0.25.3", + "@esbuild/netbsd-arm64": "0.25.3", + "@esbuild/netbsd-x64": "0.25.3", + "@esbuild/openbsd-arm64": "0.25.3", + "@esbuild/openbsd-x64": "0.25.3", + "@esbuild/sunos-x64": "0.25.3", + "@esbuild/win32-arm64": "0.25.3", + "@esbuild/win32-ia32": "0.25.3", + "@esbuild/win32-x64": "0.25.3" } }, "node_modules/esbuild-register": { @@ -6185,39 +5743,6 @@ "moment": "^2.29.1" } }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6329,6 +5854,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -6341,7 +5867,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6416,16 +5941,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -6444,6 +5974,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -6549,12 +6092,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6572,6 +6115,28 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -6615,10 +6180,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -6650,7 +6215,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -7605,25 +7169,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -8996,6 +8541,15 @@ "node": ">= 12" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/md-to-react-email": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/md-to-react-email/-/md-to-react-email-5.0.5.tgz", @@ -9667,6 +9221,13 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/next": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", @@ -10294,10 +9855,10 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "dev": true, + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10957,6 +10518,21 @@ "node": ">=10.13.0" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11443,9 +11019,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11556,15 +11132,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -11962,6 +11592,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -12136,21 +11779,20 @@ } }, "node_modules/ts-jest": { - "version": "29.3.2", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.2.tgz", - "integrity": "sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==", + "version": "29.4.1", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", + "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.1", - "type-fest": "^4.39.1", + "semver": "^7.7.2", + "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -12161,10 +11803,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -12182,6 +11825,9 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, @@ -12199,9 +11845,9 @@ } }, "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.40.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.40.1.tgz", - "integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -12413,11 +12059,10 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12432,6 +12077,20 @@ "integrity": "sha512-67Hyl94beZX8gmTap7IDPrG5hy2cHftgsCAcGvE1tzuxGT+kRB+zSBin0wIMwysYw8RUCBCvv9UfQl8TNM75dA==", "peer": true }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -12450,8 +12109,7 @@ "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==" }, "node_modules/unified": { "version": "11.0.5", @@ -12871,6 +12529,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index 9c58051b6..4161dab68 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@prisma/client": "^6.1.0", "@react-email/components": "^0.0.31", "@react-email/render": "^1.0.3", + "@stripe/react-stripe-js": "^3.3.0", + "@stripe/stripe-js": "^5.9.2", "bcrypt": "^5.1.1", "html5-qrcode": "^2.3.8", "jsonwebtoken": "^9.0.2", @@ -55,6 +57,7 @@ "sass": "^1.83.0", "server-only": "^0.0.1", "sharp": "^0.33.5", + "stripe": "^17.7.0", "unified": "^11.0.5", "uuid": "^10.0.0", "winston": "^3.17.0", diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss new file mode 100644 index 000000000..f2fa6b148 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss @@ -0,0 +1,32 @@ +@use "@/styles/ohma"; + +.LedgerAccountBalance { + display: grid; + grid-template-columns: auto 1fr auto; + grid-auto-flow: row; + grid-auto-columns: max-content; + column-gap: 2*ohma.$gap; + white-space: nowrap; + overflow: hidden; + + // @include ohma.screenMobile { + // grid-template-columns: auto 1fr; + // } + // align-items: center; + // justify-content: space-between; +} + +.amountRow { + display: contents; + font-size: ohma.$fonts-xxl; +} + +.feesRow { + display: contents; + font-size: ohma.$fonts-l; +} + +.total { + text-align: right; + // width: 100%; // ensures it stretches to the container +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx new file mode 100644 index 000000000..1bebcfaa2 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx @@ -0,0 +1,26 @@ +import styles from './LedgerAccountBalance.module.scss' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { displayAmount } from '@/lib/currency/convert' +import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' + +type Props = { + ledgerAccountId: number, + showFees?: boolean, +} + +export default async function LedgerAccountBalance({ ledgerAccountId: accountId, showFees }: Props) { + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: accountId })) + + return
+
+
Saldo
+
{displayAmount(balance.amount)}
+
Muenter
+
+ {showFees &&
+
Avgifter
+
{displayAmount(balance.fees)}
+
Muenter
+
} +
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss new file mode 100644 index 000000000..cb9944138 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss @@ -0,0 +1,5 @@ +@use "@/styles/ohma"; + +.ledgerAccountListTable { + @include ohma.table(); +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx new file mode 100644 index 000000000..34850715a --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx @@ -0,0 +1,27 @@ +'use client' + +import styles from './LedgerAccountList.module.scss' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import LedgerAccountPagingProvider, { LedgerAccountPagingContext } from '@/contexts/paging/LedgerAccountPaging' +import Link from 'next/link' + +export default function LedgerAccountList() { + return + + + + + + + + + + + + + + }/> + +
NavnSaldo
{account.name}19.19 Klinguende Muente
+
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss new file mode 100644 index 000000000..178266988 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss @@ -0,0 +1,11 @@ +@use "@/styles/ohma"; + +.ledgerAccountOverviewButtons { + margin-top: 3*ohma.$gap; + display: flex; + flex-direction: row; + + .rightAligned { + margin-left: auto; + } +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx new file mode 100644 index 000000000..068f548cb --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -0,0 +1,51 @@ +import styles from './LedgerAccountOverview.module.scss' +import LedgerAccountBalance from './LedgerAccountBalance' +import Card from '@/components/UI/Card' +import DepositModal from '@/components/Ledger/Modals/DepositModal' +import PayoutModal from '@/components/Ledger/Modals/PayoutModal' +import Button from '@/components/UI/Button' +import { getUser } from '@/auth/getUser' +import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' + +type Props = { + ledgerAccountId: number, + showFees?: boolean, + showDepositButton?: boolean, + showPayoutButton?: boolean, + showDeactivateButton?: boolean, +} + +const getCustomerSessionClientSecret = async () => { + const { user } = await getUser() + if (!user) { + return undefined + } + + const customerSessionResult = await createStripeCustomerSessionAction({ userId: user.id }) + if (!customerSessionResult.success) { + return undefined + } + + return customerSessionResult.data.customerSessionClientSecret +} + +export default async function LedgerAccountOverview({ + showFees, + ledgerAccountId, + showPayoutButton, + showDepositButton, + showDeactivateButton, +}: Props) { + const customerSessionClientSecret = showDepositButton + ? await getCustomerSessionClientSecret() + : undefined + + return + +
+ { showDepositButton && } + { showPayoutButton && } + { showDeactivateButton && } +
+
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx new file mode 100644 index 000000000..9d06ceb6d --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -0,0 +1,40 @@ +import BankCardModal from '@/components/Ledger/Modals/BankCardModal' +import Card from '@/components/UI/Card' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { readUserAction } from '@/services/users/actions' +import BooleanIndicator from '@/components/UI/BooleanIndicator' +import Link from 'next/link' +import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' + +type Props = { + userId: number, +} + +const getCustomerSessionClientSecret = async (userId: number) => { + const customerSessionResult = await createStripeCustomerSessionAction({ userId}) + if (customerSessionResult.success) { + return customerSessionResult.data.customerSessionClientSecret + } + return undefined +} + +export default async function LedgerAccountPaymentMethods({ userId }: Props) { + const user = unwrapActionReturn(await readUserAction({ id: userId })) + const customerSessionClientSecret = await getCustomerSessionClientSecret(userId) + + const hasBankCard = false // TODO: Actually check with Stripe + const hasStudentCard = user.studentCard !== null + + return +

Bankkort

+

+ Du kan lagre kortinformasjonen din for senere betalinger. + Kortinformasjonen lagres kun hos betalingsleverandøren vår, Stripe, og ikke på våre tjenere. +

+ +

NTNU-kort

+

Kortnummer: {hasStudentCard ? user.studentCard : 'ikke registrert'}

+

For å benytte Kiogeskabet på Lophtet må et NTNU-kort være registrert.

+ Gå til siden for kortregistrering. +
+} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx new file mode 100644 index 000000000..36e825d0d --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx @@ -0,0 +1,25 @@ +import Card from '@/components/UI/Card' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowRight } from '@fortawesome/free-solid-svg-icons' +import Link from 'next/link' + +type Props = { + ledgerAccountId: number, + transactionsHref?: string, +} + +export default function LedgerAccountTransactionSummary({ transactionsHref }: Props) { + return + + + + + + + + + +
En transaksjon
En annen transaksjon
+ { transactionsHref && Se alle transaksjoner } +
+} diff --git a/src/app/_components/Ledger/Modals/BankCardModal.module.scss b/src/app/_components/Ledger/Modals/BankCardModal.module.scss new file mode 100644 index 000000000..1af857906 --- /dev/null +++ b/src/app/_components/Ledger/Modals/BankCardModal.module.scss @@ -0,0 +1,10 @@ +@use "@/styles/ohma"; + +.bankCardFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} diff --git a/src/app/_components/Ledger/Modals/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx new file mode 100644 index 000000000..4a711ae08 --- /dev/null +++ b/src/app/_components/Ledger/Modals/BankCardModal.tsx @@ -0,0 +1,27 @@ +'use client' + +import styles from './BankCardModal.module.scss' +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import StripePayment from '@/components/Stripe/StripePayment' +import StripeProvider from '@/components/Stripe/StripeProvider' + +type PropTypes = { + customerSessionClientSecret?: string, +} + +export default function BankCardModal({ customerSessionClientSecret }: PropTypes) { + return ( + } + > +

Legg til bankkort

+
+ + + +
+
+ ) +} diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.module.scss b/src/app/_components/Ledger/Modals/CheckoutModal.module.scss new file mode 100644 index 000000000..dae5e9524 --- /dev/null +++ b/src/app/_components/Ledger/Modals/CheckoutModal.module.scss @@ -0,0 +1,7 @@ +.checkoutFormContainer { + width: 500px; // TODO: Is there a better way to do this? +} + +.paymentDetails { + min-height: 50px; +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx new file mode 100644 index 000000000..e27e11d94 --- /dev/null +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -0,0 +1,230 @@ +'use client' +import styles from './CheckoutModal.module.scss' +import Form from '@/components/Form/Form' +import PopUp from '@/components/PopUp/PopUp' +import Button from '@/components/UI/Button' +import { createActionError } from '@/services/actionError' +import React, { useState, lazy, Ref, useRef } from 'react' +import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/Type' +import type { PaymentProvider } from '@prisma/client' +import type { StripePaymentRef } from '../../Stripe/StripePayment' +import type { ActionReturn } from '@/services/actionTypes' + +const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) +const StripePayment = lazy(() => import('../../Stripe/StripePayment')) + +const defaultPaymentProvider: PaymentProvider = 'STRIPE' + +const paymentProviderNames: Record = { + STRIPE: 'Stripe', + MANUAL: 'Manuell Betaling', +} + +type Props = { + callback: (data: object) => Promise>, + title?: string, + showSummary?: boolean, + availableFunds?: number, + totalFunds?: number, + manualFees?: number, + sourceLedgerAccountId?: number, + targetLedgerAccountId?: number, + children?: React.ReactNode, +} + +export default function CheckoutModal({ + callback, + title = 'Betal', + showSummary = true, + totalFunds = 100, + availableFunds = 50, + manualFees = 0, + sourceLedgerAccountId, + targetLedgerAccountId, +}: Props) { + // const stripe = useStripe() + + const [paymentProvider, setPaymentProvider] = useState(defaultPaymentProvider) + const [useFunds, setUseFunds] = useState(availableFunds > 0) + + const stripePaymentRef = useRef(null) + + const fundsToTransfer = useFunds ? Math.min(totalFunds, availableFunds) : 0 + const fundsToPay = Math.max(0, totalFunds - fundsToTransfer) + + const handleSubmit = async (): Promise> => { + if (paymentProvider === 'STRIPE') { + const result = await stripePaymentRef?.current?.submit() + + if (!result) { + return createActionError('BAD DATA', 'Stripe er ikke initalisert enda.') + } + + if (!result.success) { + return result + } + } + + const result = await callback({ + ledgerEntries: [ + ...(fundsToTransfer > 0 ? [{ + ledgerAccountId: sourceLedgerAccountId, + funds: -fundsToTransfer, + }] : []), + ...(fundsToTransfer > 0 ? [{ + ledgerAccountId: targetLedgerAccountId, + funds: fundsToTransfer, + }] : []), + ], + payment: { + paymentProvider, + funds: fundsToPay, + }, + }) + + if (!result.success) return result + + const transaction = result.data + + if (transaction.payment?.state === 'PENDING') { + if (paymentProvider !== 'STRIPE' || !transaction.payment?.stripePayment) { + return createActionError('BAD DATA', 'Ugyldig betalingsdata fra server.') + } + + stripePaymentRef.current?.confirm(transaction.payment.stripePayment.clientSecret) + } + + const { payment } = result.data + return { success: true } + } + + return ( + } + > +
+
+ + +
+ Betal med... + + {Object.entries(paymentProviderNames).map(([provider, name]) => ( + + ))} +
+ +
+ {fundsToPay > 0 && ( + paymentProvider === 'STRIPE' && ( + + + + ) || + paymentProvider === 'MANUAL' && ( +
+ With great power comes great responsibility. +
A wise uncle
+
+ ) + )} +
+ {/* {amountToPay > 0 ? ( + // paymentProvider === "STRIPE" &&

Du vil bli omdirigert til Stripe for å fullføre betalingen.

|| + // paymentProvider === "MANUAL" &&

Du vil motta instruksjoner for manuell betaling via e-post etter at du har sendt inn skjemaet.

+ // ) : ( + //

Saldoen din dekker hele beløpet; ingen betaling er nødvendig.

+ // )} */} + + {showSummary && + + + + + + + + + + +
Trukket fra saldo{fundsToTransfer} Kluengende Muente
Å betale{fundsToPay} Kluengende Muente
} + {/*

Trukket fra saldo: {fundsToTransfer} Kluengende Muente.

+

Å betale: {fundsToPay} Kluengende Muente.

*/} +
+
+
+ ) +} + +{/* + + + + + + + + + + + + + + +
Tilgjengelig Saldo{displayAmount(availableFunds)} Kluengende Muente
Totalt{displayAmount(totalAmount)} Kluengende Muente
Å betal{displayAmount(amountToPay)} Kluengende Muente
*/} + + +// type PropType = { +// supportedProviders?: PaymentProvider[], +// } + +// export function CheckoutForm({ supportedProviders }: PropType) { +// const paymentProviders = [ +// { provider: "STRIPE", name: "Stripe", component: }, +// { provider: "MANUAL", name: "Manuell Betaling", component: } +// ].filter(({ provider }) => !supportedProviders || supportedProviders.includes(provider as PaymentProvider)) + +// const [selectedProvider, setSelectedProvider] = useState("STRIPE") + +// return
+//
+// Betal med... +// {paymentProviders.map(({ provider, name }) => ( +// +// ))} +//
+ +// {paymentProviders.map(({ provider, component }, i) => +// provider === selectedProvider &&
{component}
+// )} +//
+// } diff --git a/src/app/_components/Ledger/Modals/DepositModal.module.scss b/src/app/_components/Ledger/Modals/DepositModal.module.scss new file mode 100644 index 000000000..6541f78f8 --- /dev/null +++ b/src/app/_components/Ledger/Modals/DepositModal.module.scss @@ -0,0 +1,10 @@ +@use "@/styles/ohma"; + +.checkoutFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx new file mode 100644 index 000000000..4c7281f7e --- /dev/null +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -0,0 +1,138 @@ +'use client' + +import styles from './DepositModal.module.scss' +import Form from '@/components/Form/Form' +import PopUp from '@/components/PopUp/PopUp' +import NumberInput from '@/components/UI/NumberInput' +import Button from '@/components/UI/Button' +import { createDepositAction } from '@/services/ledger/ledgerOperations/actions' +import { convertAmount } from '@/lib/currency/convert' +import { createActionError } from '@/services/actionError' +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' +import Checkbox from '@/components/UI/Checkbox' +import TextInput from '@/components/UI/TextInput' +import { lazy, useRef, useState } from 'react' +import type { PaymentProvider } from '@prisma/client' +import type { ExpandedPayment } from '@/services/ledger/payments/Types' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' + +// Avoid loading the Stripe components until they are needed +const StripePayment = lazy(() => import('@/components/Stripe/StripePayment')) +const StripeProvider = lazy(() => import('@/components/Stripe/StripeProvider')) + +const defaultPaymentProvider: PaymentProvider = 'STRIPE' +const paymentProviderNames: Record = { + STRIPE: 'Stripe', + MANUAL: 'Manuell Betaling', +} + +type Props = { + ledgerAccountId: number, + customerSessionClientSecret?: string, +} + +export default function DepositModal({ ledgerAccountId, customerSessionClientSecret }: Props) { + const [funds, setFunds] = useState(MINIMUM_PAYMENT_AMOUNT) + const [manualFees, setManualFees] = useState(0) + const [selectedProvider, setSelectedProvider] = useState(defaultPaymentProvider) + + const stripePaymentRef = useRef(null) + + const confirmPayment = async (payment: ExpandedPayment) => { + // Stripe payments are the only payments that need confirmation + if (payment.provider !== 'STRIPE') return 'Ukjent betalingsleverandør.' + + // The client secret key should be set after creation + const clientSecret = payment.stripePayment?.clientSecret + if (!clientSecret) return 'Noe gikk galt ved opprettelse av betalingen.' + + // The stripe payment ref should be set when using stripe + const current = stripePaymentRef.current + if (!current) return 'Noe gikk galt ved innhenting av Stripe.' + + // Call the stripe payment ref to confirm the payment + const confirmError = await current.confirmPayment(clientSecret) + if (confirmError) return confirmError + } + + const handleSubmit = async (_: FormData) => { + // If the stripe payment ref is set, validate the input + if (stripePaymentRef.current) { + const submitError = await stripePaymentRef.current.submit() + if (submitError) return createActionError('UNKNOWN ERROR', submitError) + } + + // Call the server action to create the deposit + const createResult = await createDepositAction({ ledgerAccountId, funds, provider: selectedProvider }) + if (!createResult.success) return createResult + + // The returned transaction should have a payment + const transaction = createResult.data + const payment = transaction.payment + if (!payment) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved opprettelse av betalingen.') + + // Confirm the payment if its needed + if (payment.state === 'PENDING') { + const confirmError = await confirmPayment(payment) + if (confirmError) return createActionError('UNKNOWN ERROR', confirmError) + } + + return { success: true } as const + } + + return }> +
+

Nytt innskudd

+
+ setFunds(convertAmount(e.target.value))} + required + /> + +
+ Betal med... + + {Object.entries(paymentProviderNames).map(([provider, name]) => ( + + ))} +
+ + {selectedProvider === 'STRIPE' && ( + + + + )} + + {selectedProvider === 'MANUAL' && ( +
+ Jeg bruker dette med ohmu. + setManualFees(convertAmount(e.target.value))} + required + /> + +
+ )} + +
+
+} diff --git a/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx b/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx new file mode 100644 index 000000000..a0deee871 --- /dev/null +++ b/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx @@ -0,0 +1,16 @@ +import Checkbox from '@/components/UI/Checkbox' +import TextInput from '@/components/UI/TextInput' + +type Props = { + bankAccountNumber?: string, +} + +export default function ManualPaymentInput({ bankAccountNumber }: Props) { + return ( +
+ + + +
+ ) +} diff --git a/src/app/_components/Ledger/Modals/PayoutModal.module.scss b/src/app/_components/Ledger/Modals/PayoutModal.module.scss new file mode 100644 index 000000000..ca4dec49c --- /dev/null +++ b/src/app/_components/Ledger/Modals/PayoutModal.module.scss @@ -0,0 +1,14 @@ +@use "@/styles/ohma"; + +.checkoutFormContainer { + width: 500px; // TODO: Is there a better way to do this? + margin: ohma.$gap * 3; +} + +.paymentDetails { + min-height: 50px; +} + +.submitButton { + width: 200px; +} diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx new file mode 100644 index 000000000..f00e32138 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -0,0 +1,48 @@ +'use client' + +import styles from './PayoutModal.module.scss' +import Form from '../../Form/Form' +import PopUp from '../../PopUp/PopUp' +import NumberInput from '../../UI/NumberInput' +import Button from '../../UI/Button' +import { createPayout } from '@/services/ledger/ledgerOperations/actions' +import { convertAmount } from '@/lib/currency/convert' +import { bindParams } from '@/services/actionBind' +import { useState } from 'react' + +type Props = { + ledgerAccountId: number, + defaultFunds?: number, + defaultFees?: number, +} + +export default function PayoutModal({ ledgerAccountId, defaultFunds = 0, defaultFees = 0 }: Props) { + const [funds, setFunds] = useState(defaultFunds) + const [fees, setFees] = useState(defaultFees) + + return }> +

Ny utbetaling

+
+
+ setFunds(convertAmount(e.target.value))} + /> + setFees(convertAmount(e.target.value))} + /> + +
+
+} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss b/src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx new file mode 100644 index 000000000..6c458e7de --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -0,0 +1,22 @@ +'use client' + +import LedgerTransactionRow from './LedgerTransactionRow' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTransactionPaging' + +type Props = { + accountId: number, + showFees?: boolean, +} + +export default function TransactionList({ accountId, showFees }: Props) { + return + + } + /> + {/* TODO: Add message "Her var det tomt! Hva med å ta seg en tur innom Kiogeskapet?" when no transaksjons exist. */} + +} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss new file mode 100644 index 000000000..aab02cce2 --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss @@ -0,0 +1,12 @@ +@use '@/styles/ohma'; + +.TransactionRow { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: ohma.$gap; + background-color: ohma.$colors-gray-300; + // border: 2px solid ohma.$colors-gray-300; + border-radius: ohma.$rounding; + margin-bottom: ohma.$gap; +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx new file mode 100644 index 000000000..7b10a0170 --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -0,0 +1,21 @@ +import styles from './LedgerTransactionRow.module.scss' +import { displayAmount } from '@/lib/currency/convert' +import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/Type' + +type Props = { + transaction: ExpandedLedgerTransaction, + showFees?: boolean, +} + +export default function LedgerTransactionRow({ transaction, showFees }: Props) { + const totalFunds = transaction.ledgerEntries?.reduce((sum, entry) => sum + entry.funds, 0) + const totalFees = transaction.ledgerEntries?.reduce((sum, entry) => sum + (entry.fees ?? 0), 0) + + return +

{transaction.createdAt.toLocaleString()}

+

{displayAmount(totalFunds)}

+ {showFees &&

{transaction.ledgerEntries ? displayAmount(totalFees) : '-'}

} +

{transaction.purpose}

+

{transaction.state}

+
+} diff --git a/src/app/_components/NavBar/UserNavigation.tsx b/src/app/_components/NavBar/UserNavigation.tsx index e6f70c252..b205d995f 100644 --- a/src/app/_components/NavBar/UserNavigation.tsx +++ b/src/app/_components/NavBar/UserNavigation.tsx @@ -5,7 +5,7 @@ import BorderButton from '@/UI/BorderButton' import useClickOutsideRef from '@/hooks/useClickOutsideRef' import useOnNavigation from '@/hooks/useOnNavigation' import UserDisplayName from '@/components/User/UserDisplayName' -import { faCog, faMoneyBill, faQrcode, faSignOut, faUser } from '@fortawesome/free-solid-svg-icons' +import { faCog, faMoneyBillWave, faSignOut, faUser, faQrcode } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import Link from 'next/link' import { useState } from 'react' @@ -54,13 +54,13 @@ export default function UserNavigation({ profile }: PropTypes) {

OmegaId

- - + +

Konto

-

Instillinger

+

Innstillinger

diff --git a/src/app/_components/NavBar/navDef.ts b/src/app/_components/NavBar/navDef.ts index ed677c7fa..8b5f43241 100644 --- a/src/app/_components/NavBar/navDef.ts +++ b/src/app/_components/NavBar/navDef.ts @@ -170,7 +170,7 @@ export const itemsForMenu: NavItem[] = [ icon: faIdCard, }, { - name: 'Admin', + name: 'Administrasjon', href: '/admin', show: 'admin', icon: faTools, diff --git a/src/app/_components/PagingWrappers/EndlessScroll.tsx b/src/app/_components/PagingWrappers/EndlessScroll.tsx index 73dd81a3d..426c29348 100644 --- a/src/app/_components/PagingWrappers/EndlessScroll.tsx +++ b/src/app/_components/PagingWrappers/EndlessScroll.tsx @@ -68,7 +68,11 @@ export default function EndlessScroll {renderedPageData} - Ingen flere å laste inn + { + context.state.data.length == 0 + ? 'Ingen data å laste inn' + : 'Ingen flere å laste inn' + } diff --git a/src/app/_components/PopUp/PopUp.module.scss b/src/app/_components/PopUp/PopUp.module.scss index 348f423fa..cf28411b7 100644 --- a/src/app/_components/PopUp/PopUp.module.scss +++ b/src/app/_components/PopUp/PopUp.module.scss @@ -13,6 +13,7 @@ max-height: 95svh; background-color: ohma.$colors-white; > .overflow { + overflow-x: visible; overflow-y: auto; margin: 0; padding: 0; @@ -23,6 +24,8 @@ .closeBtn { background-color: ohma.$colors-red; + font-size: ohma.$fonts-xl; + color: ohma.$colors-white; @include ohma.roundBtn(ohma.$colors-red); } diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index f3e8a6514..53db2176f 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -4,22 +4,24 @@ import useKeyPress from '@/hooks/useKeyPress' import { PopUpContext } from '@/contexts/PopUp' import useClickOutsideRef from '@/hooks/useClickOutsideRef' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faX } from '@fortawesome/free-solid-svg-icons' +import { faXmark } from '@fortawesome/free-solid-svg-icons' import { useContext, useEffect, useState, useRef, useCallback } from 'react' import type { ReactNode, CSSProperties } from 'react' import type { PopUpKeyType } from '@/contexts/PopUp' export type PropTypes = { children: ReactNode, - showButtonContent: ReactNode, - showButtonClass?: string, PopUpKey: PopUpKeyType, + customShowButton?: (open: () => void) => ReactNode, + showButtonContent?: ReactNode, + showButtonClass?: string, showButtonStyle?: CSSProperties, } export default function PopUp({ PopUpKey, children, + customShowButton, showButtonContent, showButtonClass, showButtonStyle, @@ -54,7 +56,7 @@ export default function PopUp({
{ children } @@ -72,13 +74,17 @@ export default function PopUp({ setIsOpen(true) }, []) - return ( - - ) + return <>{ + customShowButton ? ( + customShowButton(handleOpening) + ) : ( + + ) + } } diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx new file mode 100644 index 000000000..bb7f6a00d --- /dev/null +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -0,0 +1,55 @@ +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import React, { useImperativeHandle } from 'react' + +export type StripePaymentRef = { + submit: () => Promise; + confirmPayment: (clientSecret: string) => Promise; + confirmSetup: (clientSecret: string) => Promise; +} + +type Props = { + ref?: React.Ref, +} + +export default function StripePayment({ ref }: Props) { + const stripe = useStripe() + const elements = useElements() + + useImperativeHandle(ref, () => ({ + submit: async () => { + if (!stripe || !elements) return 'Stripe er ikke initalisert enda.' + + const { error } = await elements.submit() + + if (error) return error.message || 'En feil oppsto når betalingen skulle sendes inn.' + }, + confirmPayment: async (clientSecret: string) => { + if (!stripe || !elements) return 'Stripe ikke initialisert enda.' + + const { error } = await stripe.confirmPayment({ + clientSecret, + elements, + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) return error.message || 'En feil oppsto når betalingen skulle bekreftes.' + }, + confirmSetup: async (clientSecret: string) => { + if (!stripe || !elements) return 'Stripe ikke initialisert enda.' + + const { error } = await stripe.confirmSetup({ + clientSecret, + elements, + confirmParams: { + return_url: window.location.href, + }, + }) + + if (error) return error.message || 'En feil oppsto ved lagring av informasjon.' + } + })) + + return +} diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx new file mode 100644 index 000000000..1c313a8a6 --- /dev/null +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -0,0 +1,32 @@ +'use client' + +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' +import { Elements } from '@stripe/react-stripe-js' +import { loadStripe } from '@stripe/stripe-js' +import type { ReactNode } from 'react' + +if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + throw new Error('Stripe publishable key not set') +} + +const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) + +type Props = { + children?: ReactNode, + mode: 'payment' | 'setup', + amount?: number, + customerSessionClientSecret?: string, +} + +export default function StripeProvider({ children, mode, amount, customerSessionClientSecret }: Props) { + return ( + + {children} + + ) +} diff --git a/src/app/_components/UI/BooleanIndicator.tsx b/src/app/_components/UI/BooleanIndicator.tsx new file mode 100644 index 000000000..94bc1d18b --- /dev/null +++ b/src/app/_components/UI/BooleanIndicator.tsx @@ -0,0 +1,12 @@ +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' + +type Props = { + value: boolean, +} + +export default function BooleanIndicator({ value }: Props) { + return value + ? + : +} diff --git a/src/app/_components/UI/Button.module.scss b/src/app/_components/UI/Button.module.scss index 4529b9fc0..6c5b7d45d 100644 --- a/src/app/_components/UI/Button.module.scss +++ b/src/app/_components/UI/Button.module.scss @@ -1,19 +1,19 @@ @use "@/styles/ohma"; .primary { - @include ohma.btn(ohma.$colors-primary); + @include ohma.btn(ohma.$colors-primary, ohma.$colors-black); } .secondary { - @include ohma.btn(ohma.$colors-secondary); + @include ohma.btn(ohma.$colors-secondary, ohma.$colors-black); } .green { - @include ohma.btn(ohma.$colors-green); + @include ohma.btn(ohma.$colors-green, ohma.$colors-white); } .red { - @include ohma.btn(ohma.$colors-red); + @include ohma.btn(ohma.$colors-red, ohma.$colors-white); } .button:disabled { diff --git a/src/app/_components/UI/Card.module.scss b/src/app/_components/UI/Card.module.scss new file mode 100644 index 000000000..e6864d51d --- /dev/null +++ b/src/app/_components/UI/Card.module.scss @@ -0,0 +1,16 @@ +@use "@/styles/ohma"; + +$background: ohma.$colors-white; + +.Card { + border-radius: ohma.$cardRounding; + padding: ohma.$cardRounding; + box-shadow: 0 3px 6px rgba(0, 0, 0, .16), 0 3px 6px rgba(0, 0, 0, .23); // TODO: Make this reusable + margin: 5*ohma.$gap 0; + overflow: hidden; + background-color: $background; + + > h2 { + font-size: ohma.$fonts-xl; + } +} \ No newline at end of file diff --git a/src/app/_components/UI/Card.tsx b/src/app/_components/UI/Card.tsx new file mode 100644 index 000000000..200d3faab --- /dev/null +++ b/src/app/_components/UI/Card.tsx @@ -0,0 +1,18 @@ +import styles from './Card.module.scss' +import type { ReactNode } from 'react' + +type PropTypes = { + children?: ReactNode, + heading?: string, +} + +export default function Card({ children, heading }: PropTypes) { + return ( +
+ {heading &&

{heading}

} +
+ {children} +
+
+ ) +} diff --git a/src/app/_components/UI/NumberInput.tsx b/src/app/_components/UI/NumberInput.tsx index ade5f5b34..4460e7f33 100644 --- a/src/app/_components/UI/NumberInput.tsx +++ b/src/app/_components/UI/NumberInput.tsx @@ -15,7 +15,7 @@ export default function NumberInput({ }: PropTypes) { return (
- +
) diff --git a/src/app/admin/SlideSidebar.tsx b/src/app/admin/SlideSidebar.tsx index 73754de45..181fadfb5 100644 --- a/src/app/admin/SlideSidebar.tsx +++ b/src/app/admin/SlideSidebar.tsx @@ -19,6 +19,7 @@ import { faHouse, faShop, faListDots, + faMoneyBillWave, } from '@fortawesome/free-solid-svg-icons' import type { IconDefinition } from '@fortawesome/free-solid-svg-icons' @@ -117,7 +118,7 @@ const navigations = [ href: '/admin/default-permissions' }, { - title: 'Api Nøkler', + title: 'API Nøkler', href: '/admin/api-keys' }, ], @@ -213,6 +214,18 @@ const navigations = [ }, ] }, + { + header: { + title: 'Økonomi', + icon: faMoneyBillWave, + }, + links: [ + { + title: 'Kontoer', + href: '/admin/accounts' + }, + ] + }, { header: { title: 'Annet', diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx new file mode 100644 index 000000000..844621de6 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -0,0 +1,23 @@ +import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' +import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' +import { notFound } from 'next/navigation' + +type Props = { + params: Promise<{ + accountId: string, + }>, +} + +export default async function LedgerAccount({ params }: Props) { + const accountId = Number((await params).accountId) + + if (!accountId) { + notFound() + } + + return
+ + {/* Add link to products overview */} + +
+} diff --git a/src/app/admin/accounts/[accountId]/transactions/page.tsx b/src/app/admin/accounts/[accountId]/transactions/page.tsx new file mode 100644 index 000000000..baba5a3b6 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -0,0 +1,18 @@ +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' +import { notFound } from 'next/navigation' + +type Props = { + params: Promise<{ + accountId: string, + }>, +} + +export default async function LedgerAccountTransactions({ params }: Props) { + const accountId = Number((await params).accountId) + + if (!accountId) { + notFound() + } + + return +} diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx new file mode 100644 index 000000000..89303f4a7 --- /dev/null +++ b/src/app/admin/accounts/page.tsx @@ -0,0 +1,5 @@ +import LedgerAccountList from '@/components/Ledger/Accounts/LedgerAccountList' + +export default async function LedgerAccounts() { + return +} diff --git a/src/app/admin/cabin-product/[product]/page.tsx b/src/app/admin/cabin-product/[product]/page.tsx index 79f7a1965..d4164bfb2 100644 --- a/src/app/admin/cabin-product/[product]/page.tsx +++ b/src/app/admin/cabin-product/[product]/page.tsx @@ -6,7 +6,7 @@ import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { displayDate } from '@/lib/dates/displayDate' import SimpleTable from '@/app/_components/Table/SimpleTable' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import Link from 'next/link' export default async function CabinProduct({ @@ -61,7 +61,7 @@ export default async function CabinProduct({ ]} body={product.CabinProductPrice.map(priceObj => [ priceObj.description, - displayPrice(priceObj.price), + displayAmount(priceObj.price), displayDate(priceObj.PricePeriod.validFrom, false), priceObj.memberShare.toString(), priceObj.cronInterval ?? '', diff --git a/src/app/admin/product/[productId]/page.tsx b/src/app/admin/product/[productId]/page.tsx index 61a6603ca..6b39b98e0 100644 --- a/src/app/admin/product/[productId]/page.tsx +++ b/src/app/admin/product/[productId]/page.tsx @@ -2,7 +2,7 @@ import styles from './page.module.scss' import ProductForm from '@/app/admin/product/productForm' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { readProductAction } from '@/services/shop/actions' import { v4 as uuid } from 'uuid' import Link from 'next/link' @@ -33,7 +33,7 @@ export default async function ProductPage({ params }: PropTypes) { {product.ShopProduct.map(shopProduct => {shopProduct.shop.name} {shopProduct.active ? 'AKTIV' : 'INAKTIV'} - {displayPrice(shopProduct.price, false)} + {displayAmount(shopProduct.price, false)} )} diff --git a/src/app/admin/shop/[shop]/EditProductForShopForm.tsx b/src/app/admin/shop/[shop]/EditProductForShopForm.tsx index 0d3432cd6..392f22436 100644 --- a/src/app/admin/shop/[shop]/EditProductForShopForm.tsx +++ b/src/app/admin/shop/[shop]/EditProductForShopForm.tsx @@ -5,7 +5,7 @@ import Form from '@/app/_components/Form/Form' import Checkbox from '@/app/_components/UI/Checkbox' import NumberInput from '@/app/_components/UI/NumberInput' import TextInput from '@/app/_components/UI/TextInput' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import type { ExtendedProduct } from '@/services/shop/product/types' @@ -31,6 +31,6 @@ export function EditProductForShopForm({ } - + } diff --git a/src/app/admin/shop/[shop]/page.tsx b/src/app/admin/shop/[shop]/page.tsx index 5f3da985f..731ad5f68 100644 --- a/src/app/admin/shop/[shop]/page.tsx +++ b/src/app/admin/shop/[shop]/page.tsx @@ -4,7 +4,7 @@ import FindProductForm from './FindProductForm' import PageWrapper from '@/app/_components/PageWrapper/PageWrapper' import PopUp from '@/app/_components/PopUp/PopUp' import { unwrapActionReturn } from '@/app/redirectToErrorPage' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { sortObjectsByName } from '@/lib/sortObjects' import { readShopAction, readProductsAction } from '@/services/shop/actions' import { faPencil } from '@fortawesome/free-solid-svg-icons' @@ -77,7 +77,7 @@ export default async function Shop({ params }: PropTypes) { {product.name} {product.description} - {displayPrice(product.price, false)} + {displayAmount(product.price, false)} )} diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts new file mode 100644 index 000000000..990223710 --- /dev/null +++ b/src/app/api/stripe-event/route.ts @@ -0,0 +1,42 @@ +import logger from '@/lib/logger' +import { stripe } from '@/lib/stripe' +import { DepositMethods } from '@/services/ledger/transactions/deposits/methods' + +export async function POST(req: Request) { + if (!process.env.STRIPE_WEBHOOK_SECRET) { + return new Response('Invalid server-side configuration', { status: 500 }) + } + + const stripeSignature = req.headers.get('stripe-signature') + const body = await req.text() + + if (!stripeSignature) { + return new Response('Stripe signature missing', { status: 400 }) + } + + const event = stripe.webhooks.constructEvent(body, stripeSignature, process.env.STRIPE_WEBHOOK_SECRET) + + // Check if the event is one of the expected types + if (event.type !== 'charge.succeeded' && event.type !== 'charge.updated') { + logger.warn(`Unhandled Stripe event received: ${event.type}`) + return new Response('', { status: 200 }) + } + + // Validate the event data types we need + if (typeof event.data.object.balance_transaction !== 'string' || typeof event.data.object.payment_intent !== 'string') { + return new Response('', { status: 200 }) + } + + try { + await DepositMethods.confirmStripe({ + params: { + balanceTransactionId: event.data.object.balance_transaction, + paymentIntentId: event.data.object.payment_intent, + }, + }) + } catch { + return new Response('Server-side error confirming deposit', { status: 500 }) + } + + return new Response('', { status: 200 }) +} diff --git a/src/app/cabin/book/CabinPriceCalculator.tsx b/src/app/cabin/book/CabinPriceCalculator.tsx index 1cf32e0b2..0a24127d5 100644 --- a/src/app/cabin/book/CabinPriceCalculator.tsx +++ b/src/app/cabin/book/CabinPriceCalculator.tsx @@ -1,5 +1,5 @@ import SimpleTable from '@/app/_components/Table/SimpleTable' -import { displayPrice } from '@/lib/money/convert' +import { displayAmount } from '@/lib/currency/convert' import { calculateCabinBookingPrice, calculateTotalCabinBookingPrice } from '@/services/cabin/booking/cabinPriceCalculator' import type { CabinProductExtended } from '@/services/cabin/product/constants' import type { CabinPriceCalculatorReturnType } from '@/services/cabin/booking/cabinPriceCalculator' @@ -49,9 +49,9 @@ export default function CabinPriceCalculator({ const displayName = priceRow.product.name + (description ? ` (${description})` : '') tableBody.push([ displayName, - displayPrice(priceRow.productPrice.price), + displayAmount(priceRow.productPrice.price), priceRow.amount.toString(), - displayPrice(priceRow.amount * priceRow.productPrice.price) + displayAmount(priceRow.amount * priceRow.productPrice.price) ]) } @@ -60,6 +60,6 @@ export default function CabinPriceCalculator({ header={['Produkt', 'Pris per natt', 'Antall', 'Total Pris']} body={tableBody} /> -

Total pris {displayPrice(totalPrice)}

+

Total pris {displayAmount(totalPrice)}

} diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 000000000..5fa678bb4 --- /dev/null +++ b/src/app/checkout/page.tsx @@ -0,0 +1,9 @@ +import Button from '@/components/UI/Button' + +export default async function Checkout() { + return
+

Betaling

+

Her kommer kassen din!

+ +
+} diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx index 89f071ec3..1e2a5429f 100644 --- a/src/app/users/[username]/(user-admin)/Nav.tsx +++ b/src/app/users/[username]/(user-admin)/Nav.tsx @@ -2,7 +2,7 @@ import styles from './Nav.module.scss' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import Link from 'next/link' -import { faCircleDot, faCog, faKey, faPaperPlane } from '@fortawesome/free-solid-svg-icons' +import { faCircleDot, faCog, faKey, faMoneyBillWave, faPaperPlane } from '@fortawesome/free-solid-svg-icons' import { usePathname } from 'next/navigation' type PropTypes = { @@ -32,6 +32,12 @@ export default function Nav({ username }: PropTypes) { > + + + + + + +
+} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx new file mode 100644 index 000000000..49291f075 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -0,0 +1,13 @@ +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' +import { getUser } from '@/auth/getUser' + +export default async function Transactions() { + const { user } = await getUser({ + userRequired: true, + shouldRedirect: true, + }) + + const account = { id: 1 } + + return +} diff --git a/src/app/users/[username]/(user-admin)/layout.tsx b/src/app/users/[username]/(user-admin)/layout.tsx index 0e39e9b07..3dbf27f31 100644 --- a/src/app/users/[username]/(user-admin)/layout.tsx +++ b/src/app/users/[username]/(user-admin)/layout.tsx @@ -18,7 +18,7 @@ export default async function UserAdmin({ children, params }: PropTypes & { chil } const { user } = unwrapActionReturn(await readUserProfileAction({ params: { username } })) return ( - + Til Profilsiden diff --git a/src/app/users/[username]/(user-admin)/settings/page.tsx b/src/app/users/[username]/(user-admin)/settings/page.tsx index ffefa3d44..88d7e3259 100644 --- a/src/app/users/[username]/(user-admin)/settings/page.tsx +++ b/src/app/users/[username]/(user-admin)/settings/page.tsx @@ -8,7 +8,7 @@ export default async function UserSettings({ params }: PropTypes) { return (
-

Generelle Instillinger

+

Generelle innstillinger

diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx index bb0062608..18564e18f 100644 --- a/src/app/users/[username]/page.tsx +++ b/src/app/users/[username]/page.tsx @@ -88,7 +88,7 @@ export default async function User({ params }: PropTypes) {
{canAdministrate && -

Instillinger

+

Innstillinger

} {profile.user.id === session?.user?.id && ( @@ -97,10 +97,8 @@ export default async function User({ params }: PropTypes) {

Logg ut

- ) - } + )}
-
{(profile.user.bio !== '') && diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx new file mode 100644 index 000000000..ee86d6c00 --- /dev/null +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -0,0 +1,24 @@ +'use client' + +import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { readLedgerAccountPageAction } from '@/services/ledger/ledgerAccount/actions' +import type { LedgerAccount, LedgerAccountType } from '@prisma/client' + +// TODO: These paging functions always come in pairs, can we gave one function which generates both? + +export type PageSizeTransactions = 10 + +export const LedgerAccountPagingContext = generatePagingContext< + LedgerAccount, + { id: number }, + PageSizeTransactions, + { accountType?: LedgerAccountType } +>() + +const LedgerAccountPagingProvider = generatePagingProvider({ + Context: LedgerAccountPagingContext, + fetcher: (paging) => readLedgerAccountPageAction({ paging }), + getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), +}) + +export default LedgerAccountPagingProvider diff --git a/src/contexts/paging/LedgerTransactionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx new file mode 100644 index 000000000..41b54a7f1 --- /dev/null +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -0,0 +1,26 @@ +'use client' + +import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { readLedgerTransactionPageAction } from '@/services/ledger/ledgerTransactions/actions' +import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/Type' + +// TODO: Might be possible to cleanup? Why is size a type??? + +export type PageSizeTransactions = 10 + +export const LedgerTransactionPagingContext = generatePagingContext< + ExpandedLedgerTransaction, + { id: number }, + PageSizeTransactions, + { accountId: number } +>() +const LedgerTransactionPagingProvider = generatePagingProvider({ + Context: LedgerTransactionPagingContext, + fetcher: (paging) => readLedgerTransactionPageAction({ paging }), + getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), +}) + +// TODO: The "getCursorAfterFetch" function always just accesses the last element of the array, +// can't just the last eleement be passed in directly? + +export default LedgerTransactionPagingProvider diff --git a/src/lib/money/ConfigVars.ts b/src/lib/currency/config.ts similarity index 96% rename from src/lib/money/ConfigVars.ts rename to src/lib/currency/config.ts index c7006bf02..4289ebbee 100644 --- a/src/lib/money/ConfigVars.ts +++ b/src/lib/currency/config.ts @@ -1,3 +1 @@ - - export const currencySymbol = 'Klinguende Meunt' diff --git a/src/lib/currency/convert.ts b/src/lib/currency/convert.ts new file mode 100644 index 000000000..81f3c6792 --- /dev/null +++ b/src/lib/currency/convert.ts @@ -0,0 +1,19 @@ +import { currencySymbol } from './config' + +// TODO: Verify that @Pauliusj doesn't implement a similar function +// I haven't :) -Paulius +export function convertAmount(amount: string | number): number { + if (typeof amount === 'string') { + amount = amount.replace(',', '.') + } + + return Math.round(Number(amount) * 100) +} + +export function displayAmount(amount: number, short: boolean = true): string { + const convertedamount = amount / 100 + const amountString = convertedamount.toFixed(2) + if (short) return amountString + + return `${amountString} ${currencySymbol}` +} diff --git a/src/lib/money/convert.ts b/src/lib/money/convert.ts deleted file mode 100644 index 6a97a20d9..000000000 --- a/src/lib/money/convert.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { currencySymbol } from './ConfigVars' - -/** - * Converts a price from kroner to ører - * @param price The price in kroner - * @returns The price in øre as an integer - */ -export const convertPrice = (price: string | number): number => Math.floor(Number(price) * 100) - -export function displayPrice(price: number, short: boolean = true): string { - const convertedPrice = price / 100 - const priceString = convertedPrice.toFixed(2) - if (short) return priceString - - return `${priceString} ${currencySymbol}` -} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 000000000..814589894 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,7 @@ +import Stripe from 'stripe' + +if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('Stripe secret key not set') +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { telemetry: false }) diff --git a/src/prisma/schema/group.prisma b/src/prisma/schema/group.prisma index 53e8d9c8d..db5d3208e 100644 --- a/src/prisma/schema/group.prisma +++ b/src/prisma/schema/group.prisma @@ -31,12 +31,14 @@ enum GroupType { // 'Group' should never be created by itself. It should always be created // with a reference to one specific type of group. model Group { - id Int @id @default(autoincrement()) - groupType GroupType - memberships Membership[] - omegaOrder OmegaOrder @relation(fields: [order], references: [order]) - order Int //The order the group is in currently. - + id Int @id @default(autoincrement()) + groupType GroupType + memberships Membership[] + omegaOrder OmegaOrder @relation(fields: [order], references: [order]) + order Int //The order the group is in currently. + ledgerAccount LedgerAccount? + + // The different types of groups: class Class? committee Committee? interestGroup InterestGroup? diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma new file mode 100644 index 000000000..b51666022 --- /dev/null +++ b/src/prisma/schema/ledger.prisma @@ -0,0 +1,207 @@ +// Join table between groups and their ledger accounts +model GroupLedgerAccount { + id Int @id @default(autoincrement()) + // TODO: Finnish this + + // group Group @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) + // groupId Int + // ledgerAccount LedgerAccount @relation(fields: [ledgerAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) + // ledgerAccountId Int + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Index on IDs for faster look up + // @@index([groupId]) + // @@index([ledgerAccountId]) +} + +// In theory the type of a ledger accounts could be inferred from its relations, +// but to simplify logic an enum is used. In addition this also +// makes the ledger account type known even after the relation is lost. +// Say for example if a user is deleted. +enum LedgerAccountType { + // User ledger accounts may be attached to users and + // may pay for items and event registrations. + USER + // Group ledger accounts may be attached to groups and + // can receive money from shops and events registrations. + GROUP +} + +// A ledger account is equivalent to you real life bank account +// It is used to store internal funds for either users or groups +model LedgerAccount { + id Int @id @default(autoincrement()) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade) + userId Int? @unique + group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull, onUpdate: Cascade) + groupId Int? @unique + type LedgerAccountType + name String? // Optional display name for the account, only used for group accounts + payoutAccountNumber String? // For display only, only used for group accounts + + ledgerEntries LedgerEntry[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Book keeping for why a transaction was created +// Only used for informing the user, no effect on actual logic in the ledger itself. +enum LedgerTransactionPurpose { + SHOP_PURCHASE + EVENT_PAYMENT + DEPOSIT + PAYOUT + REFUND +} + +// All ledger transactions start as pending and become +// either failed, succeeded or canceled. No other +// transitions are possible. +enum LedgerTransactionState { + PENDING + FAILED + SUCCEEDED + CANCELED +} + +// The system uses a double-entry accounting. In engineering terms this means that +// ledger transactions obey Kirchhoff's first law. That is: +// sum of ledger entries + sum of payouts = sum of all payments +// Either all or none ledger entries and payouts in a transaction are valid. +// Payments track their own state as they depend on the external payment provider. +model LedgerTransaction { + id Int @id @default(autoincrement()) + purpose LedgerTransactionPurpose + state LedgerTransactionState + reason String? // If the transaction failed, this is the reason why. + + ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique + + // Relevant relations to other tables based un purpose + // purchase Purchase + // deposit Deposit + // payout Payout + // refund + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model LedgerEntry { + id Int @id @default(autoincrement()) + // The funds this ledger entry moves + // Credit when > 0, debit when < 0 + funds Int + // Fees are the fees incurred during payment. This does not effect users balance and are only used for book keeping. + // Optional since fees might not be known until payment is confirmed + // Must be non-null when completing a transaction. It must be explicitly set to 0 to indicate no fees. + fees Int? + // The account which should be credited/debited on completions + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int + // The transaction this ledger entry is part of + // Accounts are credited only when the transaction succeeds. + // Accounts are debited immediately when the transaction is created, + // but this is reversed in case the transaction fails (essentially the funds are reserved) + ledgerTransaction LedgerTransaction @relation(fields: [ledgerTransactionId], references: [id]) + ledgerTransactionId Int // The entry is only valid once the transaction is valid + + // TODO: Should we have updated and created at per ledger entry? + // TODO: Add indexes for fields which are used for look up often to increase performance + // @@index([ledgerAccountId]) + // @@index([ledgerTransactionId]) + // @@index([paymentId]) + + // Only one ledger entry for a given account may be present in a transaction + // This is for simplicity as they could always be merged + @@unique([ledgerTransactionId, ledgerAccountId]) +} + +enum PaymentProvider { + STRIPE + MANUAL +} + +enum PaymentState { + // Payment created, but not external API call made + PENDING + // Awaiting response from payment provider (webhook) + PROCESSING + // Failed webhook received :( + FAILED + // Succeed webhook received with correct funds + SUCCEEDED + // Cancel webhook confirmation received - NOT that we initiated a cancel + // set the transaction state to canceled for that + CANCELED +} + +// Payments represent external movement of funds and may be both incoming and outgoing. +// Incoming payments are for example when users deposit money into their account. +// Outgoing payments are for example when we payout money to committees. +model Payment { + id Int @id @default(autoincrement()) + // The funds that was requested for this payment + // Use to confirm that the correct funds was captured + // Note: positive = going into the ledger, negative = going out of the ledger + funds Int + // The fees this payment incurred + fees Int? + // The reason for the payment, displayed on the stripe dashboard + descriptionLong String? + // The text displayed on the bank statement + descriptionShort String? + // The life cycle state of this payment + state PaymentState + // The responsible provider for this payment + provider PaymentProvider + // Only one of the following relations may be set + // depending on the payment provider used + stripePayment StripePayment? + manualPayment ManualPayment? + + // Which ledger transaction this payment is part + ledgerTransaction LedgerTransaction? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model StripePayment { + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @id + + paymentIntentId String? @unique + // The key the fronted uses to confirm the payment intent + clientSecret String? @unique + + // Which ledger entries have used this payment + // Useful in case payment goes through, + // then user can be credited unused funds + // into their account. + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Bookkeeping for when funds are transferred to or from the ledger manually by administrators. +// Important: The actual funds transferred is `funds` minus `fees`! +// Example: Say PhaestCom has earned 50'000.00 funds and 1000.00 fees. +// Then, the actual bank transfer should equate to 49'000.00. +model ManualPayment { + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @id + + // The bank account number where the money was sent to/from. + // This is only for our own bookkeeping. When sending funds out + // of the system it has to be transferred manually by an admin! + bankAccountNumber String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index c31cd5548..65e5a5ccb 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -5,41 +5,69 @@ enum SEX { } model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique - firstname String @default("[Fjernet]") - lastname String @default("[Fjernet]") - bio String @default("") - archived Boolean @default(false) - acceptedTerms DateTime? - sex SEX? - allergies String? - mobile String? - emailVerified DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // is also updated manually - image Image? @relation(fields: [imageId], references: [id]) - imageId Int? - studentCard String? @unique + id Int @id @default(autoincrement()) + username String @unique + email String @unique + firstname String @default("[Fjernet]") + lastname String @default("[Fjernet]") + bio String @default("") + archived Boolean @default(false) + acceptedTerms DateTime? + sex SEX? + allergies String? + mobile String? + emailVerified DateTime? + image Image? @relation(fields: [imageId], references: [id]) // TODO: Rename to "profilePicture"? + imageId Int? + studentCard String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // is also updated manually + + // Authentication info used for logging in. + credentials Credentials? + feideAccount FeideAccount? + + // Memberships to groups (committees, interest groups, classes, etc...). + memberships Membership[] + + // Lockers used by the user. LockerReservation LockerReservation[] + + // Omega quotes posted by the user. omegaQuote OmegaQuote[] - memberships Membership[] - credentials Credentials? - feideAccount FeideAccount? + // Which ledger account (i.e. internal bank account) and + // stripe customer this user is associated with. + ledgerAccount LedgerAccount? + stripeCustomer StripeCustomer? + + // What notifications the user whiches to received and + // which mailing lists the user is on. notificationSubscriptions NotificationSubscription[] mailingLists MailingListUser[] - admissionTrials AdmissionTrial[] @relation(name: "user") - registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") - Application Application[] - EventRegistration EventRegistration[] - dots DotWrapper[] @relation(name: "dot_user") - dotsAccused DotWrapper[] @relation(name: "dot_accuser") + // Which admissions (a.k.a. "opptak") the user has taken + // and which admissions thay have registered for others. + admissionTrials AdmissionTrial[] @relation(name: "user") + registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") + + // The user's applications to committees. + Application Application[] + + // Which events the user has registered for + // and which events they have created. + EventRegistration EventRegistration[] + Event Event[] + + // Which dots (a.k.a. "prikker") the user has received and given. + dots DotWrapper[] @relation(name: "dot_user") + dotsAccused DotWrapper[] @relation(name: "dot_accuser") + + // The queue used to determine who is registering cards at Kiogeskabet. registerStudentCardQueue RegisterStudentCardQueue[] - Event Event[] + // Which cabin bookings the user has made. cabinBooking Booking[] @relation() // We need to explicitly mark the combination of 'id', 'username' and 'email' as @@ -47,6 +75,8 @@ model User { @@unique([id, username, email]) } +// This model primaraly exists to keep the password hash separate from the user table. +// This is to reduce the risk of leaking the password hashes.. model Credentials { user User @relation(fields: [userId, username, email], references: [id, username, email], onDelete: Cascade, onUpdate: Cascade) userId Int @unique @@ -60,22 +90,36 @@ model Credentials { @@unique([userId, username, email]) } +// Associates each user with their Feide account. model FeideAccount { id String @id accessToken String @db.Text email String @unique expiresAt DateTime issuedAt DateTime - userId Int @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId Int @unique +} + +// Associates each user with their Stripe customer id. +model StripeCustomer { + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId Int @id + customerId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } +// When a user wants to register their student card, they are put in this queue. +// Then they must scan their card with the card reader at Kiogeskabet. model RegisterStudentCardQueue { userId Int @id user User @relation(fields: [userId], references: [id], onDelete: Cascade) expiry DateTime } +// TODO: Someone should add a comment for ContactDetails because I have noe idea what it is for. Is it for anonymous users? model ContactDetails { id Int @id @default(autoincrement()) name String diff --git a/src/prisma/seeder/src/development/seedDevGroups.ts b/src/prisma/seeder/src/development/seedDevGroups.ts index c11209a85..41eac8823 100644 --- a/src/prisma/seeder/src/development/seedDevGroups.ts +++ b/src/prisma/seeder/src/development/seedDevGroups.ts @@ -13,14 +13,14 @@ export default async function seedDevGroups(prisma: PrismaClient) { await prisma.committee.create({ data: { - name: 'Harambe\'s komité', + name: 'Harambes komité', shortName: 'harcom', committeeArticle: { create: { - name: 'Harambe\'s komité', + name: 'Harambes komité', coverImage: { create: { - name: 'Harambe\'s bilde' + name: 'Harambes bilde' } } } @@ -35,6 +35,12 @@ export default async function seedDevGroups(prisma: PrismaClient) { create: { groupType: 'COMMITTEE', order: order.order, + ledgerAccount: { + create: { + name: `Kontoen til Harambes komité`, + type: 'GROUP', + }, + }, }, }, logoImage: { diff --git a/src/prisma/seeder/src/development/seedDevUsers.ts b/src/prisma/seeder/src/development/seedDevUsers.ts index 090f82199..02cd42faf 100644 --- a/src/prisma/seeder/src/development/seedDevUsers.ts +++ b/src/prisma/seeder/src/development/seedDevUsers.ts @@ -144,6 +144,11 @@ export default async function seedDevUsers(prisma: PrismaClient) { id: harambeImage.id } }, + ledgerAccount: { + create: { + type: 'USER', + }, + }, emailVerified: new Date(), acceptedTerms: new Date(), }, @@ -203,7 +208,12 @@ export default async function seedDevUsers(prisma: PrismaClient) { studentCard: 'vever', credentials: { create: { - passwordHash: 'password', + passwordHash, + }, + }, + ledgerAccount: { + create: { + type: 'USER', }, }, emailVerified: new Date(), diff --git a/src/services/cabin/product/schemas.ts b/src/services/cabin/product/schemas.ts index bd8af5166..36b783128 100644 --- a/src/services/cabin/product/schemas.ts +++ b/src/services/cabin/product/schemas.ts @@ -1,5 +1,5 @@ import { Zpn } from '@/lib/fields/zpn' -import { convertPrice } from '@/lib/money/convert' +import { convertAmount } from '@/lib/currency/convert' import { BookingType } from '@prisma/client' import { z } from 'zod' @@ -8,7 +8,7 @@ const baseSchema = z.object({ amount: z.coerce.number().int().min(0), name: z.string().min(2), description: z.string().min(0).max(20), - price: z.coerce.number().min(0).transform((val) => convertPrice(val)), + price: z.coerce.number().min(0).transform((val) => convertAmount(val)), validFrom: z.coerce.date(), cronInterval: Zpn.simpleCronExpression(), memberShare: z.coerce.number().min(0).max(100), diff --git a/src/services/ledger/ledgerAccount/Types.ts b/src/services/ledger/ledgerAccount/Types.ts new file mode 100644 index 000000000..34bcb86f3 --- /dev/null +++ b/src/services/ledger/ledgerAccount/Types.ts @@ -0,0 +1,10 @@ +// NOTE: `amount` and `fees` are stored as integers representing +// hundredths (1/100) of a Kluengende Muent. +// (We should have a name for this. "Kluengende Cent"? "Kluengende Muentling"?) +export type Balance = { + amount: number, + fees: number, +} + +// TODO: Should this also be partial? It cannot possibly contain all number IDs. +export type BalanceRecord = Record diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts new file mode 100644 index 000000000..d6396d1ce --- /dev/null +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { LedgerAccountMethods } from './methods' +import { action } from '@/services/action' + +export const calculateLedgerAccountBalanceAction = action(LedgerAccountMethods.calculateBalance) +export const readLedgerAccountPageAction = action(LedgerAccountMethods.readPage) diff --git a/src/services/ledger/ledgerAccount/authers.ts b/src/services/ledger/ledgerAccount/authers.ts new file mode 100644 index 000000000..70b786d12 --- /dev/null +++ b/src/services/ledger/ledgerAccount/authers.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts new file mode 100644 index 000000000..8077461af --- /dev/null +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -0,0 +1,212 @@ +import { LedgerAccountSchemas } from './schemas' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServerError } from '@/services/error' +import { serviceMethod } from '@/services/serviceMethod' +import { stripe } from '@/lib/stripe' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { z } from 'zod' +import { LedgerAccountType } from '@prisma/client' +import type { BalanceRecord } from './Types' + +export namespace LedgerAccountMethods { + /** + * Creates a new ledger account for given user or group. + * + * Will throw an error if both `userId` and `groupId` are set, or if neither are set. + * + * @param data.userId The ID of the user to create the account for. + * @param data.groupId The ID of the group to create the account for. + * + * @returns The created account. + */ + export const create = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + dataSchema: LedgerAccountSchemas.create, + method: async ({ prisma, data }) => { + const type = data.userId === undefined ? 'GROUP' : 'USER' + + if (data.userId === undefined && data.groupId === undefined) { + throw new ServerError('BAD PARAMETERS', 'Enten bruker-id eller gruppe-id må være spesifisert.') + } + + if (data.userId !== undefined && data.groupId !== undefined) { + throw new ServerError('BAD PARAMETERS', 'Både bruker-id og gruppe-id kan ikke være spesifisert samtidig.') + } + + return prisma.ledgerAccount.create({ + data: { + userId: data.userId, + groupId: data.groupId, + payoutAccountNumber: data.payoutAccountNumber, + type, + } + }) + }, + }) + + /** + * Reads details of a ledger account for a given user or group. + * The account will be created if it does not exist. + * + * **Note**: The balance of an account is not included in the response. + * Use the `calculateBalance` method to get the balance. + * + * @param params.userId The ID of the user to read the account for. + * @param params.groupId The ID of the group to read the account for. + * + * @returns The account details. + */ + export const read = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.union([ + z.object({ + userId: z.number(), + groupId: z.undefined(), + }), + z.object({ + groupId: z.number(), + userId: z.undefined(), + }), + ]), + method: async ({ prisma, session, params }) => { + const account = await prisma.ledgerAccount.findUnique({ + where: { + userId: params.userId, + groupId: params.groupId, + }, + }) + + if (account) return account + + return create({ session, data: params }) + }, + }) + + export const readPage = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: readPageInputSchemaObject( + z.number(), + z.object({ + id: z.number(), + }), + z.object({ + accountType: z.nativeEnum(LedgerAccountType).optional(), + }), + ), + method: async ({ params: { paging }, prisma }) => + // TODO: Add balance to each account + await prisma.ledgerAccount.findMany({ + where: { + type: paging.details.accountType, + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(paging.page), + }) + + }) + + /** + * Calculates the balance and fees of a ledger account. Optionally takes a transaction ID to calculate the balance up until that transaction. + * + * @warning Non-existent accounts will be treated as having a balance of zero. + * + * @param params.ids The IDs of the accounts to calculate the balance for. + * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + * + * @returns The balances of the ledger accounts. + */ + export const calculateBalances = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + ids: z.number().array(), + atTransactionId: z.number().optional(), + }), + method: async ({ prisma, params }): Promise => { + const balanceArray = await prisma.ledgerEntry.groupBy({ + by: ['ledgerAccountId'], + where: { + // Select which accounts we want to calculate the balance for + ledgerAccountId: { + in: params.ids, + }, + // Since transaction ids are sequential we can use the less than operator + // to filter for all the transactions that happened before the given one. + // This is useful in case we need to know the balance in the past. + ledgerTransactionId: { + lte: params.atTransactionId, + }, + // Credit and debit ledger entries are valid under slight different conditions. + OR: [ + { + // If the amount is greater than zero the entry is a credit (i.e. giving money). + funds: { gt: 0 }, + // The receiver should (logically) only receive the money if the transaction succeeded. + ledgerTransaction: { state: 'SUCCEEDED' }, + }, + { + // If the amount is less than zero the entry is a debit (i.e. taking money). + funds: { lt: 0 }, + // The amount should be deducted from the source if the transaction succeeded (obviously) + // OR when the transaction is pending. This is our way of reserving the funds + // until the transaction is complete. + ledgerTransaction: { state: { in: ['PENDING', 'SUCCEEDED'] } }, + }, + ], + }, + // Select what fields we should sum + _sum: { + funds: true, + fees: true, + }, + }) + + // Convert the array to an object as it's more convenient for lookups and + // replace all nulls with zeros to handle accounts with no entries yet. + // Set the balance of accounts that have no entries to zero. + const balanceRecord = Object.fromEntries([ + ...params.ids.map(id => [id, { amount: 0, fees: 0 }]), + ...balanceArray.map(balance => [ + balance.ledgerAccountId, + { + amount: balance._sum.funds ?? 0, + fees: balance._sum.fees ?? 0 + } + ]) + ]) + + return balanceRecord + } + }) + + /** + * Calcultates the balance of a single account. Under the hood it simply uses `calculateBalances`. + * + * @warning In case a ledger account with the provided id doesn't exist a balance of zero will be returned! + * + * @param params.id The ID of the account to calculate the balance for. + * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + * + * @returns The balances of the ledger accounts. + */ + export const calculateBalance = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + atTransactionId: z.number().optional(), + }), + method: async ({ params }) => { + const balances = await calculateBalances({ + params: { + ids: [params.id], + atTransactionId: params.atTransactionId, + }, + }) + + return balances[params.id] + } + }) +} diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/ledgerAccount/schemas.ts new file mode 100644 index 000000000..d394216f2 --- /dev/null +++ b/src/services/ledger/ledgerAccount/schemas.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +export namespace LedgerAccountSchemas { + const fields = z.object({ + userId: z.number().optional(), + groupId: z.number().optional(), + payoutAccountNumber: z.string().optional(), + }) + + export const create = fields.pick({ + userId: true, + groupId: true, + payoutAccountNumber: true, + }).refine( + data => (data.userId === undefined) !== (data.groupId === undefined), + 'Bruker- eller gruppe-ID må være satt.' + ) + + export const update = fields.partial().pick({ + payoutAccountNumber: true, + }) +} diff --git a/src/services/ledger/ledgerOperations/actions.ts b/src/services/ledger/ledgerOperations/actions.ts new file mode 100644 index 000000000..a0ff5221a --- /dev/null +++ b/src/services/ledger/ledgerOperations/actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { LedgerOperationMethods } from './methods' +import { action } from '@/services/action' + +export const createDepositAction = action(LedgerOperationMethods.createDeposit) +export const createPayout = action(LedgerOperationMethods.createPayout) diff --git a/src/services/ledger/ledgerOperations/methods.ts b/src/services/ledger/ledgerOperations/methods.ts new file mode 100644 index 000000000..03f1b74c9 --- /dev/null +++ b/src/services/ledger/ledgerOperations/methods.ts @@ -0,0 +1,106 @@ +import { LedgerTransactionMethods } from '../ledgerTransactions/methods' +import { PaymentMethods } from '../payments/methods' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { serviceMethod } from '@/services/serviceMethod' +import { z } from 'zod' +import { PaymentProvider } from '@prisma/client' + +// `LedgerOperations` provides functions to orchestrate account related actions, +// such as depositing funds or creating payouts. If the ledger is needed for +// other purposes, such as creating a transaction, it should be done through +// `LedgerTransaction`. + +export namespace LedgerOperationMethods { + /** + * Creates a deposit transaction, which is a deposit of funds into the ledger. + * + * @params params.amount The amount to be deposited. + * @params params.ledgerAccountId The ID of the ledger account where the funds will be deposited. + * + * @return The created transaction representing the deposit operation. + */ + export const createDeposit = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + opensTransaction: true, + paramsSchema: z.object({ + ledgerAccountId: z.number(), + provider: z.nativeEnum(PaymentProvider), + funds: z.coerce.number().positive(), + }), + method: async ({ prisma, params }) => { + const transaction = await prisma.$transaction(async tx => { + const payment = await PaymentMethods.create({ + params: { + provider: params.provider, + funds: params.funds, + descriptionLong: 'Innskudd', + descriptionShort: 'Innskudd', + }, + }) + + const transaction = await LedgerTransactionMethods.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + funds: params.funds, + }], + paymentId: payment.id, + }, + }) + + return transaction + }) + + if (transaction.payment?.state === 'PENDING') { + transaction.payment = await PaymentMethods.initiate({ + params: { paymentId: transaction.payment.id }, + }) + } + + return transaction + } + }) + + /** + * Creates a payout transaction, which is a withdrawal of funds from the ledger. + * + * @params params.amount The amount to be withdrawn. + * @params params.fees The fees associated with the payout. + * @params params.ledgerAccountId The ID of the ledger account from which the funds will be withdrawn. + * + * @returns The created transaction representing the payout operation. + */ + export const createPayout = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + ledgerAccountId: z.number(), + funds: z.number().nonnegative().default(0), + fees: z.number().nonnegative().default(0), + }).refine((data) => data.funds || data.fees, 'Både beløp og avgifter kan ikke være 0 samtidig.'), + opensTransaction: true, + method: async ({ prisma, params }) => prisma.$transaction(async tx => { + const payment = await PaymentMethods.create({ + params: { + provider: 'MANUAL', + descriptionLong: 'Utbetaling', + descriptionShort: 'Utbetaling', + funds: -params.funds, + }, + }) + + const transaction = await LedgerTransactionMethods.create({ + params: { + purpose: 'PAYOUT', + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + funds: -params.funds, + }], + paymentId: payment.id, + }, + }) + + return transaction + }) + }) +} diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/ledgerOperations/schemas.ts new file mode 100644 index 000000000..5f7cfc90b --- /dev/null +++ b/src/services/ledger/ledgerOperations/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export namespace LedgerOperationSchemas { + export const createDepositSchema = z.object({ + }) + + // export const createPayoutSchema = z.object({ + // funds: z.coerce.number().nonnegative(), + // fees: z.coerce.number().nonnegative(), + // }).refine((data) => data.funds || data.fees, "Både beløp og avgifter kan ikke være 0 samtidig."); +} diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/Type.ts new file mode 100644 index 000000000..e9e772a14 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/Type.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + } +}> diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts new file mode 100644 index 000000000..4a769479f --- /dev/null +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -0,0 +1,6 @@ +'use server' + +import { LedgerTransactionMethods } from './methods' +import { action } from '@/services/action' + +export const readLedgerTransactionPageAction = action(LedgerTransactionMethods.readPage) diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts new file mode 100644 index 000000000..6b95098fb --- /dev/null +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -0,0 +1,77 @@ +import type { BalanceRecord } from '@/services/ledger/ledgerAccount/Types' + +/** + * Calculates fees proportional to the ratio between `entryAmount` and `totalAmount`. + * + * **Example:** Say an account has amount = 100 Kl.M. and fees = 20 Kl.M. + * Deducting 25 Kl.M. is 25% of the total amount, so the fees deducted + * should also be 25% of the total fees, i.e., 5 Kl.M. + */ +export function feesFormula(entryAmount: number, totalAmount: number, totalFees: number) { + if (entryAmount === 0 || totalAmount === 0) return 0 + + const fees = Math.trunc(totalFees * entryAmount / totalAmount) + + // Clamp fees to have same sign as amount + // and never exceed total fees. + if (entryAmount > 0) { + return Math.min(Math.max(fees, 0), totalFees) + } + return Math.min(Math.max(fees, -totalFees), 0) +} + +/** + * Calculates the fees for debit ledger entries (funds < 0) based on + * the balances of the accounts which are deducted. + */ +export function calculateDebitFees(ledgerEntries: { funds: number, ledgerAccountId: number }[], balances: BalanceRecord) { + const debitLedgerEntries = ledgerEntries.filter(entry => entry.funds < 0) + + return Object.fromEntries(debitLedgerEntries.map(entry => { + const balance = balances[entry.ledgerAccountId] + + if (!balance) throw Error(`Balance for ledger account nr. ${entry.ledgerAccountId} not provided.`) + + return [entry.ledgerAccountId, feesFormula(entry.funds, balance.amount, balance.fees)] + })) +} + +/** + * Calculates the fees for credit ledger entries (funds > 0) based on + * the total amount and total fees of in the transaction. + */ +export function calculateCreditFees( + ledgerEntries: { funds: number, fees: number | null, ledgerAccountId: number }[], + payment: { funds: number, fees: number | null } | null, +) { + // If payment is attached but fees are null, + // return null until it completes. + if (payment && payment.fees === null) return null + + const creditLedgerEntries = ledgerEntries.filter(entry => entry.funds > 0) + const debitLedgerEntries = ledgerEntries.filter(entry => entry.funds < 0) + + const sum = (...values: (number | null | undefined)[]) => + values.reduce((total, value) => total + (value ?? 0), 0) + + let totalFunds = sum( + ...debitLedgerEntries.map(entry => -entry.funds), + payment?.funds, + ) + let totalFees = sum( + ...debitLedgerEntries.map(entry => -(entry.fees ?? 0)), + payment?.fees, + ) + + return Object.fromEntries(creditLedgerEntries.map(entry => { + const fees = feesFormula(entry.funds, totalFunds, totalFees) + + // Subtract the from the totals to ensure + // that the sum of all fees ends up exactly + // equal to `totalFees`. + totalFunds -= entry.funds + totalFees -= fees + + return [entry.ledgerAccountId, fees] + })) +} diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts new file mode 100644 index 000000000..530df6581 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -0,0 +1,174 @@ +import type { ExpandedLedgerTransaction } from './Type' +import type { BalanceRecord } from '@/services/ledger/ledgerAccount/Types' +import type { LedgerTransactionState, PaymentState } from '@prisma/client' + +type LedgerTransactionTransition = { + state: LedgerTransactionState, + reason?: string, +} + +type LedgerTransactionRule = ( + transaction: ExpandedLedgerTransaction, + balances: BalanceRecord, +) => LedgerTransactionTransition | null + +/** + * Determines the state of a given transaction. + */ +export async function determineTransactionState( + transaction: ExpandedLedgerTransaction, + balances: BalanceRecord, +): Promise { + // NOTE: The order of the rules are important! + // Fee checks must run only after payment completes + // since fees aren't set earlier. + const rules: LedgerTransactionRule[] = [ + noTerminalState, + noFailedTransfer, + amountAndFeesHaveSameSigns, + validAmountSum, + sufficientBalances, + transfersComplete, + noNullFees, + validFeesSum, + ] + + for (const rule of rules) { + const state = rule(transaction, balances) + + if (state) return state + } + + return { state: 'SUCCEEDED' } +} + +/** + * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) + * can never change state. + */ +function noTerminalState( + { state }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + if (state !== 'PENDING') return { state } + + return null +} + +/** + * If any payment has failed, the entire transaction has failed. + */ +function noFailedTransfer( + { payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + const okStates: PaymentState[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] + const hasFailedTransfer = payment && !okStates.includes(payment.state) + + if (hasFailedTransfer) return { state: 'FAILED', reason: 'Betaling mislyktes.' } + + return null +} + +/** + * Check that ledger entries, payment and manual transfer have correct signs. + * + * Mathematically: `amount > 0 <=> fees > 0` and `amount < 0 <=> fees < 0`. + */ +function amountAndFeesHaveSameSigns( + { ledgerEntries, payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + // Helper function which return true when a and b have same signs or at least + // one of a and b are falsy. + const sameSigns = (a?: number | null, b?: number | null) => !a || !b || Math.sign(a) === Math.sign(b) + + const validEntries = ledgerEntries.every(entry => sameSigns(entry.funds, entry.fees)) + const validTransfer = !payment || sameSigns(payment.funds, payment.fees) + + if (!validEntries || !validTransfer) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } + + return null +} + +/** + * Kirchhoff's first law! The sum of all amounts must be zero. + * I.e. money must come from somewhere and go to somewhere. + */ +function validAmountSum( + { ledgerEntries, payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + // NOTE: Since the number of entries in a transaction is very low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const totalLedgerEntryFunds = ledgerEntries.reduce((sum, entry) => sum + entry.funds, 0) + const paymentFunds = payment?.funds ?? 0 + + if (totalLedgerEntryFunds !== paymentFunds) return { state: 'FAILED', reason: 'Ugyldig totalbeløp.' } + + return null +} + +/** + * If an entry is debit (amount < 0), its referenced account must + * have a positive balance after the transaction succeeds. + */ +function sufficientBalances( + { ledgerEntries }: ExpandedLedgerTransaction, + balances: BalanceRecord +): LedgerTransactionTransition | null { + const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.funds < 0).map(entry => entry.ledgerAccountId) + const debitBalances = debitLedgerAccountIds.map(id => balances[id]) + + if (debitBalances.some(balance => !balance)) { + throw new Error('Missing balance in balance record.') + } + + const hasNegativeBalance = debitBalances.some(balance => balance.amount < 0 || balance.fees < 0) + + if (hasNegativeBalance) return { state: 'FAILED', reason: 'Ikke nok midler for å utføre transaksjonen.' } + + return null +} + +/** + * If any payment is pending, the transaction is pending. + */ +function transfersComplete( + { payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + // Since we have checked for failure states above, + // we can simply check that the transfer has not succeeded. + const hasPendingTransfer = payment && payment.state !== 'SUCCEEDED' + + if (hasPendingTransfer) return { state: 'PENDING' } + + return null +} + +/** + * All fees must be non-null. + */ +function noNullFees( + { ledgerEntries, payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + const hasNullFees = + ledgerEntries.some(entry => entry.fees === null) || + payment?.fees === null + + if (hasNullFees) return { state: 'FAILED', reason: 'Manglende gebyrer.' } + + return null +} + +/** + * Fees must also follow Kirchhoff's first law. + */ +function validFeesSum( + { ledgerEntries, payment }: ExpandedLedgerTransaction +): LedgerTransactionTransition | null { + // NOTE: Since the number of entries in a transaction is very low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const totalLedgerEntryFees = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = payment?.fees ?? 0 + + if (totalLedgerEntryFees !== paymentFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } + + return null +} diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts new file mode 100644 index 000000000..063c4f794 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -0,0 +1,222 @@ +import { calculateCreditFees, calculateDebitFees } from './calculateFees' +import { determineTransactionState } from './determineTransactionState' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { ServerError } from '@/services/error' +import { serviceMethod } from '@/services/serviceMethod' +import { LedgerTransactionPurpose } from '@prisma/client' +import { z } from 'zod' +import type { Prisma } from '@prisma/client' + +export namespace LedgerTransactionMethods { + /** + * Reads a single transaction including its ledger entries, payment and manual transfer (if any). + */ + export const read = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + id: z.number(), + }), + method: async ({ prisma, params }) => { + const transaction = await prisma.ledgerTransaction.findUniqueOrThrow({ + where: { + id: params.id, + }, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + }, + }) + + return transaction + } + }) + + /** + * Read several ledger transactions including its ledger entries, payment and manual transfer (if any). + */ + export const readPage = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: readPageInputSchemaObject( + z.number(), + z.object({ + id: z.number(), + }), + z.object({ + accountId: z.number(), + }), + ), + method: async ({ prisma, params }) => prisma.ledgerTransaction.findMany({ + where: { + ledgerEntries: { + some: { + ledgerAccountId: params.paging.details.accountId, + }, + }, + }, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(params.paging.page) + }) + }) + + /** + * Tries to advance the transactions state to a terminal state. + * Also, updates the fees if possible. + */ + export const advance = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + id: z.number(), + }), + method: async ({ prisma, params }) => { + let transaction = await read({ + params: { id: params.id }, + }) + + const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) + + if (creditFees) { + const creditEntries = transaction.ledgerEntries.filter(entry => entry.funds > 0) + + const ledgerEntryUpdateInput = creditEntries.map(entry => ({ + where: { + id: entry.id, + }, + data: { + fees: creditFees[entry.ledgerAccountId], + }, + })) satisfies Prisma.LedgerEntryUpdateWithWhereUniqueWithoutLedgerTransactionInput[] // X_x + + await prisma.ledgerTransaction.update({ + where: { + id: params.id, + }, + data: { + ledgerEntries: { + update: ledgerEntryUpdateInput, + }, + }, + }) + + transaction.ledgerEntries.forEach(entry => { + entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees + }) + } + + const balances = await LedgerAccountMethods.calculateBalances({ + params: { + ids: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), + atTransactionId: transaction.id, + }, + }) + + const transition = await determineTransactionState(transaction, balances) + + // We use `updateMany` in stead of just `update` here because + // we don't want to throw in case the record is not found. + await prisma.ledgerTransaction.updateMany({ + where: { + id: params.id, + state: 'PENDING', // Protect against changing final state. + }, + data: transition, + }) + + transaction = await read({ + params: { id: params.id }, + }) + + return transaction + } + }) + + /** + * Create a new transaction on the ledger with the given entries and optionally + * link to the provided payment and/or manual transfer. + * + * The fees transferred are automatically calculated. + * + * The lifecycle of the transaction is automatically handled by the system. + */ + export const create = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO, + paramsSchema: z.object({ + purpose: z.nativeEnum(LedgerTransactionPurpose), + ledgerEntries: z.object({ + funds: z.number(), + ledgerAccountId: z.number(), + }).array(), + paymentId: z.number().optional(), + }), + method: async ({ prisma, params },) => { + // Calculate the balance for all accounts which are going to be deducted + const debitEntries = params.ledgerEntries.filter(entry => entry.funds < 0) + const balances = await LedgerAccountMethods.calculateBalances({ + params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, + }) + + // Check that the relevant accounts have enough balance to do the transaction. + // NOTE: This is check is only to avoid calling the db unnecessarily. + // The actual validation is handled in the `advance` function. + const hasInsufficientBalance = debitEntries.some( + entry => (balances[entry.ledgerAccountId]?.amount ?? 0) + entry.funds < 0 + ) + if (hasInsufficientBalance) { + throw new ServerError('BAD PARAMETERS', 'Konto har for lav balanse for å utføre transaksjonen.') + } + + // Calculate and set fees for the debit entries + const fees = calculateDebitFees(params.ledgerEntries, balances) + const entries = params.ledgerEntries.map(entry => ({ + ...entry, + fees: fees[entry.ledgerAccountId] ?? null + })) + + const { id } = await prisma.ledgerTransaction.create({ + data: { + purpose: params.purpose, + state: 'PENDING', + ledgerEntries: { + create: entries, + }, + paymentId: params.paymentId, + }, + select: { + id: true, + }, + }) + + const transaction = await advance({ + params: { + id, + }, + }) + + if (transaction.state === 'FAILED') { + // TODO: Better error message. + throw new ServerError('BAD PARAMETERS', transaction.reason ?? 'Transaksjonen feilet av ukjent årsak.') + } + + return transaction + } + }) +} diff --git a/src/services/ledger/payments/Types.ts b/src/services/ledger/payments/Types.ts new file mode 100644 index 000000000..130da10be --- /dev/null +++ b/src/services/ledger/payments/Types.ts @@ -0,0 +1,8 @@ +import type { Prisma } from '@prisma/client' + +export type ExpandedPayment = Prisma.PaymentGetPayload<{ + include: { + stripePayment: true, + manualPayment: true, + }, +}> diff --git a/src/services/ledger/payments/config.ts b/src/services/ledger/payments/config.ts new file mode 100644 index 000000000..98685cb0c --- /dev/null +++ b/src/services/ledger/payments/config.ts @@ -0,0 +1 @@ +export const MINIMUM_PAYMENT_AMOUNT = 50_00 // In hundredths of Kluengende Muente diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts new file mode 100644 index 000000000..a0c745fdd --- /dev/null +++ b/src/services/ledger/payments/methods.ts @@ -0,0 +1,143 @@ +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { stripe } from '@/lib/stripe' +import { ServerError } from '@/services/error' +import { serviceMethod } from '@/services/serviceMethod' +import { PaymentProvider } from '@prisma/client' +import { z } from 'zod' + + +export namespace PaymentMethods { + /** + * Creates a new payment record in the db. + * Important: This method does not call external APIs to enable it to be used in transactions. + * Call `initiate` to actually begin collecting the payment. + */ + export const create = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.intersection( + z.object({ + funds: z.number(), + descriptionLong: z.string().optional(), + descriptionShort: z.string().optional(), + ledgerAccountId: z.number().optional(), + }), + z.discriminatedUnion('provider', [ + z.object({ + provider: z.literal(PaymentProvider.STRIPE), + details: z.object({}).optional(), + }), + z.object({ + provider: z.literal(PaymentProvider.MANUAL), + details: z.object({ + bankAccountNumber: z.string().optional(), + fees: z.number().nonnegative().default(0), + }).optional(), + }), + ]), + ), + method: async ({ prisma, params }) => { + const { details = {}, ...paymentData } = params + + return prisma.payment.create({ + data: { + ...paymentData, + // Manual payments are automatically succeeded + state: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', + fees: params.provider === 'MANUAL' ? 0 : undefined, + stripePayment: params.provider === 'STRIPE' ? { + create: params.details, + } : undefined, + manualPayment: params.provider === 'MANUAL' ? { + create: params.details, + } : undefined, + }, + include: { + stripePayment: true, + manualPayment: true, + } + }) + }, + }) + + /** + * Calls the external API to begin collecting the payment. + * + * @warning Do not call this method for manual payments! It will fail. + */ + export const initiate = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + paymentId: z.number(), + }), + // This method does not actually open a transaction. However, it cannot be used + // inside a transaction as it does external API calls which cannot be reversed. + opensTransaction: true, + method: async ({ prisma, params }) => { + const payment = await prisma.payment.findUniqueOrThrow({ + where: { + id: params.paymentId, + }, + select: { + funds: true, + provider: true, + state: true, + descriptionLong: true, + descriptionShort: true, + }, + }) + + if (payment.state !== 'PENDING') { + throw new ServerError('BAD PARAMETERS', 'Betalingen har allerede blitt forespurt.') + } + + switch (payment.provider) { + case 'MANUAL': + throw new ServerError('BAD PARAMETERS', 'Manuelle betalinger trenger ikke å startes.') + + case 'STRIPE': + const paymentIntent = await stripe.paymentIntents.create({ + amount: payment.funds, + currency: 'nok', + description: payment.descriptionLong ?? undefined, + statement_descriptor_suffix: payment.descriptionShort ?? undefined, + // Stripe allows us to attach arbitrary metadata to payment intents + // Currently, we don't use this for anything, but it might be + // useful in the future. + metadata: { + projectNextPaymentId: params.paymentId, + }, + }, { + // The idempotency key makes it so that multiple requests with the + // same key return the same result. This is useful in case + // initiate payment is accidentally called twice. + idempotencyKey: `project-next-payment-id-${params.paymentId}`, + }) + + if (paymentIntent.client_secret === null) { + throw new ServerError('UNKNOWN ERROR', 'Noe gikk galt med forespørselen til Stripe.') + } + + return await prisma.payment.update({ + where: { + id: params.paymentId, + }, + data: { + stripePayment: { + update: { + paymentIntentId: paymentIntent.id, + }, + }, + state: 'PROCESSING', + }, + include: { + stripePayment: true, + manualPayment: true, + } + }) + + default: + throw new ServerError('SERVER ERROR', 'Prøvde å forespørre betalingsleverandør som ikke er støttet.') + } + }, + }) +} diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts new file mode 100644 index 000000000..95d07fa2c --- /dev/null +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -0,0 +1,128 @@ +import logger from '@/lib/logger' +import { stripe } from '@/lib/stripe' +import { prisma } from '@/prisma/client' +import type Stripe from 'stripe' +import type { PaymentState } from '@prisma/client' + +/** + * Utility function which extracts the `latest_charge.balance_transaction` object + * from the provided payment intent object if it exists. + * + * @param paymentIntent + * @returns + */ +function extractBalanceTransaction(paymentIntent: Stripe.PaymentIntent): Stripe.BalanceTransaction | null { + const latestCharge = paymentIntent.latest_charge + + if (!latestCharge || typeof latestCharge !== 'object') { + logger.error( + 'Stripe payment intent event was missing latest charge object.' + + `'latest_charge': ${latestCharge}` + ) + return null + } + + const balanceTransaction = latestCharge.balance_transaction + + if (!balanceTransaction || typeof balanceTransaction !== 'object') { + logger.error( + 'Stripe payment intent event was missing balance transaction object.' + + `'balance_transaction': ${balanceTransaction}` + ) + return null + } + + return balanceTransaction +} + +// Map between Stripe event types and our internal payment states. +const EVENT_TYPE_TO_STATE: Partial> = { + 'payment_intent.canceled': 'CANCELED', + 'payment_intent.succeeded': 'SUCCEEDED', + 'payment_intent.payment_failed': 'FAILED', +} + +/** + * The function which is called when we receive a payment intent event from Stripe. + * It expects that the fields `latest_charge.balance_transaction` are expanded. + * (This is configured in the Stripe dashboard.) + * + * @warning This callback assumes that the Stripe payment intents always have the capture method "automatic". + * If this ever changes this function needs to be changed to handle uncaptured payments. + * (That is payments which are authorized, but we have not actually taken the money yet.) + * + * This is not implemented using `ServiceMethod` because it does not need any of its features. + * Firstly, the webhook callback is not part of the interface of the payment service. + * This function will only ever be used one place. Secondly, authentication and data validation + * is already handled by the Stripe package. + * + * @param paymentIntent The payment intent object received in the webhook. + * It is expected that `latest_charge.balance_transaction` is expanded. + * + * @returns An appropriate `Response`. + */ +export async function stripeWebhookCallback(event: Stripe.Event): Promise { + const paymentState = EVENT_TYPE_TO_STATE[event.type] + + if (!paymentState) { + logger.error('Received unsupported Stripe event type.') + return new Response('Unsupported Stripe event type', { status: 400 }) + } + + // TypeScript cannot figure out that the above if statement narrows the possible event type + // so we'll have to assert this our selves + const paymentIntent = event.data.object as Stripe.PaymentIntent + + // Declare fee, it will be undefined by default + // which is what we want for the canceled and failed events + let fee + + // If the payment succeeded we'll extract the fee + if (event.type === 'payment_intent.succeeded') { + const balanceTransaction = extractBalanceTransaction(paymentIntent) + + if (!balanceTransaction) { + logger.error('Received successful payment intent event without balance transaction object.') + return new Response('', { status: 400 }) + } + + fee = balanceTransaction.fee + } + + // Update the db model with the updated values + const stripePayment = await prisma.stripePayment.update({ + where: { + paymentIntentId: paymentIntent.id, + payment: { + state: { + // Guard against changing final state + // This should never happen, but you can never be too careful + in: ['PENDING', 'PROCESSING', paymentState] + }, + }, + }, + data: { + payment: { + update: { + fees: fee, + state: paymentState, + }, + } + }, + select: { + paymentId: true, + }, + }) + + // We only allow one payment attempt per payment intent. + // If this failed we cancel the payment intent to make sure it cannot be used in the future. + if (event.type === 'payment_intent.payment_failed') { + stripe.paymentIntents.cancel( + paymentIntent.id, + {}, + { idempotencyKey: `project-next-payment-id-${stripePayment.paymentId}` }, + ) + } + + return new Response('', { status: 200 }) +} diff --git a/src/services/notifications/email/mailHandler.ts b/src/services/notifications/email/mailHandler.ts index f064c20ce..324b7b044 100644 --- a/src/services/notifications/email/mailHandler.ts +++ b/src/services/notifications/email/mailHandler.ts @@ -5,7 +5,8 @@ import type SMTPPool from 'nodemailer/lib/smtp-pool' import type SMTPTransport from 'nodemailer/lib/smtp-transport' import type Mail from 'nodemailer/lib/mailer' -const PROD = process.env.NODE_ENV === 'production' +const isProd = process.env.NODE_ENV === 'production' +const isTest = process.env.NODE_ENV === 'test' type Transporter = nodemailer.Transporter @@ -43,7 +44,7 @@ class MailHandler { } async setupTransporter() { - if (PROD) { + if (isProd) { this.transporter = nodemailer.createTransport(TRANSPORT_OPTIONS) this.resolveSetup() console.log('Email setup in production') @@ -69,7 +70,7 @@ class MailHandler { } async getTestAccount(): Promise { - if (PROD) { + if (isProd) { throw new Error('TestAccount should only be used in development') } @@ -87,6 +88,8 @@ class MailHandler { } async handleNewMail() { + if (isTest) return + const transporter = await this.getTransporter() const responsePromises = [] @@ -104,7 +107,7 @@ class MailHandler { console.log(`MAIL SENT: ${response.envelope.from} -> (${response.envelope.to.join(' ')})`) console.log(response.response) - if (!PROD) { + if (!isProd) { console.log(`Preview: ${nodemailer.getTestMessageUrl(response as SMTPTransport.SentMessageInfo)}`) } }) @@ -115,7 +118,7 @@ class MailHandler { } async sendBulkMail(data: Mail.Options[]) { - const testSender = PROD ? null : (await this.getTestAccount()).user + const testSender = isProd ? null : (await this.getTestAccount()).user const queue = data .map(mailData => ({ diff --git a/src/services/shop/product/schemas.ts b/src/services/shop/product/schemas.ts index 1d4e48275..c683e5950 100644 --- a/src/services/shop/product/schemas.ts +++ b/src/services/shop/product/schemas.ts @@ -1,12 +1,12 @@ import { Zpn } from '@/lib/fields/zpn' -import { convertPrice } from '@/lib/money/convert' +import { convertAmount } from '@/lib/currency/convert' import { z } from 'zod' const baseSchema = z.object({ shopId: z.coerce.number().int(), name: z.string().min(3), description: z.string(), - price: z.coerce.number().int().min(1).transform((val) => convertPrice(val)), + price: z.coerce.number().int().min(1).transform((val) => convertAmount(val)), barcode: z.string().or(z.number()).optional(), active: Zpn.checkboxOrBoolean({ label: 'Active' }), productId: z.coerce.number().int(), diff --git a/src/services/stripeCustomers/actions.ts b/src/services/stripeCustomers/actions.ts new file mode 100644 index 000000000..f6e7cb809 --- /dev/null +++ b/src/services/stripeCustomers/actions.ts @@ -0,0 +1,6 @@ +'use server' + +import { action } from "../action" +import { StripeCustomerMethods } from "./methods" + +export const createStripeCustomerSessionAction = action(StripeCustomerMethods.createSession) diff --git a/src/services/stripeCustomers/methods.ts b/src/services/stripeCustomers/methods.ts new file mode 100644 index 000000000..9ce1a151c --- /dev/null +++ b/src/services/stripeCustomers/methods.ts @@ -0,0 +1,146 @@ +import { stripe } from '@/lib/stripe' +import { serviceMethod } from '@/services/serviceMethod' +import { z } from 'zod' +import { ServerError } from '../error' +import { RequireUserId } from '@/auth/auther/RequireUserId' + +export namespace StripeCustomerMethods { + /** + * If a user already has a Stripe customer associated it is returned. + * Otherwise, a new customer is created, associated in the DB, and returned. + */ + export const readOrCreate = serviceMethod({ + // No one should ever be able to retrieve the customer id of another user. NOT EVEN ADMINS! + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + method: async ({ params: { userId }, prisma }) => { + // We query the user table and not the StripeCustomer table here + // because we also need to fetch the user's email and name in + // case the Stripe customer does not exist and we need to create it. + const { stripeCustomer, ...user } = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + select: { + stripeCustomer: { + select: { + customerId: true, + }, + }, + email: true, + firstname: true, + lastname: true, + } + }) + + // Stripe customers have only a single name field. + const name = `${user.firstname} ${user.lastname}` + + // If the user doesn't already have a Stripe customer, we need to create one. + if (!stripeCustomer) { + // The information we store in the customer is only for out convienience + // when looking at the Stripe dashboard. This information is never actually + // used in the code. + const customer = await stripe.customers.create({ + email: user.email, + name, + metadata: { + userId: userId.toString(), + }, + }) + + return await prisma.stripeCustomer.create({ + data: { + userId, + customerId: customer.id, + }, + select: { + customerId: true, + }, + }) + } + + // Otherwise, we can just return the existing customer. + // But, we'll first verify that it is not deleted and that + // the stored information are up to date. + + const customer = await stripe.customers.retrieve(stripeCustomer.customerId) + + if (customer.deleted) { + // This should never happen as we never delete customers in Stripe. + throw new ServerError( + 'SERVER ERROR', + 'Stripe kunden tilknyttet brukeren er slettet. Vennligst kontakt Vevcom.', + ) + } + + if (customer.email !== user.email || customer.name !== `${user.firstname} ${user.lastname}`) { + await stripe.customers.update(stripeCustomer.customerId, { + email: user.email, + name, + metadata: { + userId: userId.toString(), + }, + }) + + } + + return { + customerId: stripeCustomer.customerId, + } + } + }) + + /** + * Creates a Strip customer session which allows the frontend to manage the saved payment methods + * for the user. This session is a one time use object and needs to be created each time it is needed. + * + * If the user does not have a Stripe customer associated it will be created automatically. + */ + export const createSession = serviceMethod({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + method: async ({ params: { userId } }) => { + const { customerId } = await readOrCreate({ params: { userId } }) + + // I havent seen much about this customer session API on the internet. + // I guess it must be rather new? Here is a link to the docs in case you wonder how it works: + // https://docs.stripe.com/payments/accept-a-payment-deferred?platform=web&type=payment#save-payment-methods + // https://docs.stripe.com/api/customer_sessions/create + const customerSession = await stripe.customerSessions.create({ + components: { + payment_element: { + enabled: true, + features: { + // Show all payment methods, even those that are "limited" or "unspecified" in display. + payment_method_allow_redisplay_filters: ['always', 'limited', 'unspecified'], + // Enable avaialble payment methods to be shown for the user. + payment_method_redisplay: 'enabled', + // Max allowed by Stripe, not that anyone will ever reach this lol. + payment_method_redisplay_limit: 10, + // Allow removal of payment methods. + payment_method_remove: 'enabled', + // Allow new payment methods to be added. + payment_method_save: 'enabled', + // Specify that new payment methods will be used manually by the user. + // (As opposed to automatically by the server, for example a subscription.) + payment_method_save_usage: 'on_session', + } + } + }, + customer: customerId, + }) + + // The customer session is a one time use object, so we don't need to (nor should we) store it in the DB. + + // Only return what is needed by the frontend. + return { + customerSessionClientSecret: customerSession.client_secret, + } + } + }) +} diff --git a/src/services/users/constants.ts b/src/services/users/constants.ts index fd21e4d5e..59d59734b 100644 --- a/src/services/users/constants.ts +++ b/src/services/users/constants.ts @@ -18,6 +18,7 @@ export const userFieldsToExpose = [ 'acceptedTerms', 'sex', 'allergies', + 'studentCard', ] as const satisfies (keyof User)[] export const userFilterSelection = createSelection([...userFieldsToExpose]) diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 44552b240..c99f64637 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -40,14 +40,14 @@ } } -@mixin btn($color: colors.$white) { +@mixin btn($color: colors.$white, $textColor: colors.$black) { text-align: center; text-decoration: none; font-size: fonts.$m; background: $color; padding: 2*variables.$gap; margin: variables.$gap; - color: colors.$black; + color: $textColor; border: none; transition: .5s background; &:hover { diff --git a/tests/services/context.test.ts b/tests/services/context.test.ts new file mode 100644 index 000000000..9acc96850 --- /dev/null +++ b/tests/services/context.test.ts @@ -0,0 +1,47 @@ +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { Session } from '@/auth/Session' +import { serviceMethod } from '@/services/serviceMethod' +import { prisma as globalPrisma } from '@/prisma/client' +import { describe, test, expect } from '@jest/globals' +import type { ServiceMethodContext } from '@/services/serviceMethod' + +const returnContextInfo = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async ({ prisma, session }) => ({ + inTransaction: '$transaction' in prisma, + apiKeyId: session.apiKeyId, + }) +}) + +const callReturnContextInfo = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async () => returnContextInfo({}) +}) + +describe('context', () => { + const apiKeySession = Session.fromJsObject({ + apiKeyId: 0, + user: null, + memberships: [], + permissions: [], + }) + const emptySession = Session.empty() + + const contexts: ServiceMethodContext[] = [ + { session: emptySession, prisma: globalPrisma, bypassAuth: false }, + { session: apiKeySession, prisma: globalPrisma, bypassAuth: false }, + { session: emptySession, prisma: globalPrisma, bypassAuth: true }, + ] + + test.each(contexts)('should work', async (context) => { + const expected = { + inTransaction: '$transaction' in context.prisma, + apiKeyId: context.session?.apiKeyId, + } + + for (const func of [returnContextInfo, callReturnContextInfo]) { + const res = await func(context) + expect(res).toMatchObject(expected) + } + }) +}) diff --git a/tests/services/ledger/calculateFees.test.ts b/tests/services/ledger/calculateFees.test.ts new file mode 100644 index 000000000..9f45d4527 --- /dev/null +++ b/tests/services/ledger/calculateFees.test.ts @@ -0,0 +1,46 @@ +import { feesFormula } from '@/services/ledger/ledgerTransactions/calculateFees' +import { describe, expect, test } from '@jest/globals' + +type FeeInputOutput = [ + { + entryAmount: number, + totalAmount: number, + totalFees: number, + }, + number +] + +describe('ledger entry fees calculation', () => { + const expectedInputOutput: FeeInputOutput[] = [ + // "Normal" cases + [{ entryAmount: 100, totalAmount: 100, totalFees: 10 }, 10], + [{ entryAmount: 50, totalAmount: 100, totalFees: 10 }, 5], + // Flooring required + [{ entryAmount: 33, totalAmount: 100, totalFees: 10 }, 3], + [{ entryAmount: 25, totalAmount: 100, totalFees: 10 }, 2], + // Zero amount + [{ entryAmount: 0, totalAmount: 100, totalFees: 10 }, 0], + // Insufficient balance + [{ entryAmount: 100, totalAmount: 0, totalFees: 10 }, 0], + [{ entryAmount: 0, totalAmount: 0, totalFees: 10 }, 0], + // No fees + [{ entryAmount: 10, totalAmount: 10, totalFees: 0 }, 0], + [{ entryAmount: 0, totalAmount: 10, totalFees: 0 }, 0], + // Exceeding maximum + [{ entryAmount: 100, totalAmount: 10, totalFees: 9 }, 9], + [{ entryAmount: 100, totalAmount: 1, totalFees: 8 }, 8], + ] + + // NOTE: We use `toBeCloseTo` to handle +0 and -0 correctly. + // Since fees are always integers it has no effect on the precision. + + test.each(expectedInputOutput)('credit ledger entry fees', ({ entryAmount, totalAmount, totalFees }, expectedFees) => { + const fees = feesFormula(entryAmount, totalAmount, totalFees) + expect(fees).toBeCloseTo(expectedFees) + }) + + test.each(expectedInputOutput)('debit ledger entry fees', ({ entryAmount, totalAmount, totalFees }, expectedFees) => { + const fees = feesFormula(-entryAmount, totalAmount, totalFees) + expect(fees).toBeCloseTo(-expectedFees) + }) +}) diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts new file mode 100644 index 000000000..537492497 --- /dev/null +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -0,0 +1,9 @@ +import { describe, test } from '@jest/globals' + +describe('ledger accounts', () => { + const testEntries = [ + [100_00, [{ amount: 100_00, fees: 10_00 }]], + ] + test('balance', async () => { + }) +}) diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts new file mode 100644 index 000000000..4f0ec4c0f --- /dev/null +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -0,0 +1,144 @@ +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/methods' +import { PaymentMethods } from '@/services/ledger/payments/methods' +import { UserMethods } from '@/services/users/methods' +import { allSettledOrThrow } from 'tests/utils' +import { prisma } from '@/prisma/client' +import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' + +const TEST_ACCOUNT_COUNT = 3 +const INITIAL_BALANCE = { amount: 100_00, fees: 10_00 } + +describe('ledger transactions', () => { + const testAccountIds: number[] = [] + + // Set up ledger accounts + beforeAll(async () => { + // TODO: Create utility to create test accounts + await allSettledOrThrow(Array.from({ length: TEST_ACCOUNT_COUNT }).map(async (_, i) => { + const username = `testuser${i + 1}` + + const testUser = await UserMethods.create({ + data: { + email: `${username}@example.com`, + firstname: 'Test', + lastname: 'User', + username, + }, + bypassAuth: true, + }) + + const testAccount = await LedgerAccountMethods.create({ + data: { + userId: testUser.id, + }, + bypassAuth: true, + }) + + testAccountIds.push(testAccount.id) + })) + }) + + afterEach(async () => { + await prisma.ledgerEntry.deleteMany({}) + await prisma.ledgerTransaction.deleteMany({}) + }) + + describe('external transactions', () => { + + }) + + describe('internal transactions', () => { + beforeEach(async () => { + await allSettledOrThrow(testAccountIds.map(async accountId => { + const manualPayment = await PaymentMethods.create({ + params: { + funds: INITIAL_BALANCE.amount, + provider: 'MANUAL', + details: { + fees: INITIAL_BALANCE.fees, + }, + }, + }) + + await LedgerTransactionMethods.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: accountId, + funds: INITIAL_BALANCE.amount, + }], + paymentId: manualPayment.id, + } + }) + }) + ) + }) + + const validLedgerEntries: number[][] = [ + // No entries + [], + // Transfer between two accounts + [100_00, -100_00], + // Transfer between three accounts - two debits and one credit + [100_00, -50_00, -50_00], + // Transfer between three accounts - two credits and one debit + [-100_00, 50_00, 50_00], + ] + + test.each(validLedgerEntries)('valid internal transactions', async (...entries) => { + const transaction = await LedgerTransactionMethods.create({ + params: { + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + expect(transaction).toMatchObject({ + state: 'SUCCEEDED', + }) + + const balances = await LedgerAccountMethods.calculateBalances({ + params: { ids: testAccountIds }, + }) + + entries.forEach((amount, i) => { + const accountId = testAccountIds[i] + const balance = balances[accountId] + + expect(balance.amount).toBe(INITIAL_BALANCE.amount + amount) + }) + }) + + const invalidLedgerEntries: number[][] = [ + // Only one entry + [100], + [-100], + // Non-zero sum + [100_00, -99_00], + [-1919, 1000_00], + [100_00, -50_00, -50_01], + ] + + test.each(invalidLedgerEntries)('invalid internal transactions', async (...entries) => { + const transactionPromise = LedgerTransactionMethods.create({ + params: { + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + await expect(transactionPromise).rejects.toThrow() + + const balances = await LedgerAccountMethods.calculateBalances({ + params: { ids: testAccountIds }, + }) + + testAccountIds.forEach(accountId => { + const balance = balances[accountId] + + expect(balance.amount).toBe(INITIAL_BALANCE.amount) + }) + }) + }) +}) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts new file mode 100644 index 000000000..e4a69394b --- /dev/null +++ b/tests/services/ledger/payments.test.ts @@ -0,0 +1,89 @@ + +// TODO: +// jest.mock('@/lib/stripe', () => ({ +// stripe: { +// paymentIntent: { +// create: jest.fn(), +// cancel: jest.fn(), +// }, +// }, +// })) + +import { Smorekopp } from '@/services/error' +import { PaymentMethods } from '@/services/ledger/payments/methods' +import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' +import { prisma } from '@/prisma/client' +import { PaymentProvider } from '@prisma/client' +import { describe, test, expect, beforeEach, beforeAll } from '@jest/globals' +import type Stripe from 'stripe' + +const TEST_PAYMENT_DEFAULTS = { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'STRIPE', + description: 'Test betaling', + descriptor: 'Test betaling', +} + +describe.skip('payments', () => { + beforeAll(async () => { + await prisma.ledgerAccount.createMany({ + data: Array(2).fill({ type: 'USER' }), + }) + }) + + beforeEach(async () => { + await prisma.ledgerEntry.deleteMany() + await prisma.payment.deleteMany() + }) + + test.each([PaymentProvider.MANUAL, PaymentProvider.STRIPE])('payment flow', async (provider) => { + let payment = await PaymentMethods.create({ + params: { + ...TEST_PAYMENT_DEFAULTS, + provider, + }, + }) + + if (payment.state === 'PENDING') { + payment = await PaymentMethods.initiate({ + params: { + paymentId: payment.id, + }, + }) + + stripeWebhookCallback({ + type: 'payment_intent.succeeded', + data: { + object: { + amount: payment.amount, + latest_charge: { + balance_transaction: { + fee: payment.amount / 100, + }, + }, + }, + }, + } as Stripe.PaymentIntentSucceededEvent) + } + + expect(payment).toMatchObject({ + state: 'SUCCEEDED', + }) + }) + + test('initiate manual payment', async () => { + const payment = await PaymentMethods.create({ + params: { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'MANUAL', + description: 'Test betaling', + descriptor: 'Test betaling', + }, + }) + + expect(PaymentMethods.initiate({ params: { paymentId: payment.id } })) + .rejects.toThrow(new Smorekopp('BAD DATA')) + }) +}) diff --git a/tests/services/ledger/payouts.test.ts b/tests/services/ledger/payouts.test.ts new file mode 100644 index 000000000..ce1e5cd08 --- /dev/null +++ b/tests/services/ledger/payouts.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from '@jest/globals' + +describe('payouts', () => { + test('nothing', () => { + + }) +}) diff --git a/tests/services/ledger/transactions.test.ts b/tests/services/ledger/transactions.test.ts new file mode 100644 index 000000000..66e43a917 --- /dev/null +++ b/tests/services/ledger/transactions.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from '@jest/globals' + +describe('transactions', () => { + test('nothing', () => { + + }) +}) diff --git a/tests/setup.ts b/tests/setup.ts index 2caefe6ce..48c77f9bc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,11 @@ import seed from '@/prisma/seeder/src/seeder' -import { beforeAll } from '@jest/globals' +import { beforeAll, jest } from '@jest/globals' + +// React email rendering uses dynamic imports which are not supported in Jest by default. +// We mock the render function to avoid issues during tests. +jest.mock('@react-email/render', () => ({ + render: jest.fn().mockImplementation(() => 'Email rendering is disabled during tests.'), +})) beforeAll( async () => await seed(false, false, false), diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 000000000..74894e617 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,27 @@ +/** + * Waits for all promises to settle and returns their results. + * Throws an error if any promise rejects, with `cause` containing all rejection reasons. + * + * This is useful for ensuring that all asynchronous operations complete before proceeding. + * Specifically, in cases where multiple database operations are ongoing even if one fails. + * + * @param promises Array of promises to wait for. + * @returns Resolved values of all fulfilled promises. + * @throws {Error} If any promise rejects. + */ +export async function allSettledOrThrow(promises: Promise[]): Promise { + const results = await Promise.allSettled(promises) + + const rejected = results.filter(result => result.status === 'rejected') + rejected.forEach(result => { + console.error('Promise rejected:', result.reason) + }) + if (rejected.length > 0) { + throw new Error('Some promises rejected.', { + cause: rejected.map(result => result.reason), + }) + } + + const fulfilled = results.filter(result => result.status === 'fulfilled') + return fulfilled.map(result => result.value) +}