From 84dd97c9ea3ec8919048aee38d901551f6bf509a Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 10 Mar 2025 23:09:08 +0100 Subject: [PATCH 01/62] feat: services for accounts, desposits and payouts --- package-lock.json | 1822 +++-------------- package.json | 2 + src/actions/ledger/ledgerAccount.ts | 8 + src/actions/ledger/transactions/deposits.ts | 6 + src/actions/ledger/transactions/payouts.ts | 6 + src/app/_components/NavBar/UserNavigation.tsx | 4 +- src/app/checkout/page.tsx | 9 + src/app/users/[username]/(user-admin)/Nav.tsx | 8 +- .../[username]/(user-admin)/account/page.tsx | 80 + .../account/transactions/page.tsx | 5 + .../[username]/(user-admin)/settings/page.tsx | 2 +- src/app/users/[username]/page.tsx | 6 +- src/prisma/schema/group.prisma | 14 +- src/prisma/schema/ledger.prisma | 41 + src/prisma/schema/user.prisma | 1 + src/services/ledger/ledgerAccount/authers.ts | 0 src/services/ledger/ledgerAccount/methods.ts | 124 ++ .../ledger/ledgerAccount/validation.ts | 35 + .../ledger/transactions/deposits/methods.ts | 23 + .../transactions/deposits/validation.ts | 19 + src/services/ledger/transactions/methods.ts | 6 + .../ledger/transactions/payouts/methods.ts | 23 + .../ledger/transactions/payouts/validation.ts | 19 + 23 files changed, 768 insertions(+), 1495 deletions(-) create mode 100644 src/actions/ledger/ledgerAccount.ts create mode 100644 src/actions/ledger/transactions/deposits.ts create mode 100644 src/actions/ledger/transactions/payouts.ts create mode 100644 src/app/checkout/page.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/page.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/transactions/page.tsx create mode 100644 src/prisma/schema/ledger.prisma create mode 100644 src/services/ledger/ledgerAccount/authers.ts create mode 100644 src/services/ledger/ledgerAccount/methods.ts create mode 100644 src/services/ledger/ledgerAccount/validation.ts create mode 100644 src/services/ledger/transactions/deposits/methods.ts create mode 100644 src/services/ledger/transactions/deposits/validation.ts create mode 100644 src/services/ledger/transactions/methods.ts create mode 100644 src/services/ledger/transactions/payouts/methods.ts create mode 100644 src/services/ledger/transactions/payouts/validation.ts diff --git a/package-lock.json b/package-lock.json index 1a76315cb..84572b7b4 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", @@ -95,9 +97,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", - "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "dev": true, "license": "MIT", "engines": { @@ -105,22 +107,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", - "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", + "@babel/generator": "^7.26.9", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.7", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.26.7", - "@babel/types": "^7.26.7", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -159,14 +161,14 @@ } }, "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.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -396,27 +398,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "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.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.26.9" }, "bin": { "parser": "bin/babel-parser.js" @@ -733,32 +735,32 @@ } }, "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.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "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.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", "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", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -777,9 +779,9 @@ } }, "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.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "dev": true, "license": "MIT", "dependencies": { @@ -805,34 +807,6 @@ "node": ">=0.1.90" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -843,832 +817,186 @@ "kuler": "^2.0.0" } }, - "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==", - "license": "MIT", - "optional": true, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { - "tslib": "^2.4.0" + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "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==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", + "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "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==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", "engines": { - "node": ">=18" + "node": ">=6" } }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz", + "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "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==", - "cpu": [ - "arm64" - ], + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, "engines": { - "node": ">=18" + "node": ">=10.10.0" } }, - "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==", - "cpu": [ - "x64" - ], + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "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==", + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "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", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", "cpu": [ - "arm" + "x64" ], - "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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==", + "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", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", "cpu": [ - "arm64" + "x64" ], - "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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==", + "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", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", - "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", - "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz", - "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.7.2", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", - "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.7.2" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/react-fontawesome": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", - "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", - "dependencies": { - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "@fortawesome/fontawesome-svg-core": "~1 || ~6", - "react": ">=16.3" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "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", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "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", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "funding": { - "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", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" + "x64" ], "optional": true, "os": [ @@ -1684,100 +1012,25 @@ "@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", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "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-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==", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", "cpu": [ "x64" ], "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" } }, "node_modules/@isaacs/cliui": { @@ -2447,70 +1700,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", - "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", - "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", - "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", - "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@next/swc-linux-x64-gnu": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", @@ -2543,38 +1732,6 @@ "node": ">= 10" } }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", - "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", - "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2660,139 +1817,6 @@ "@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", @@ -2831,63 +1855,6 @@ "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" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", - "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "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==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher/node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -3311,6 +2278,29 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.3.0.tgz", + "integrity": "sha512-Qg4rUxhHNm8OPFuUPnzsU5eLYWhpKKMMs378f67BD7vG0RKttmeeaUDjObs83imRlSxv5L6WdDKiv3RXi/RfSw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.9.2.tgz", + "integrity": "sha512-g8qabMEu8zoXujyebWHkeQrM6k9Dm8h22FUaMNIFFTv4GtrWBhLYphhsra/PBEcM3p+mRr/srEIj9g9RV5d0xg==", + "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", @@ -3326,42 +2316,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3897,21 +2851,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -4021,15 +2960,6 @@ "node": ">=10" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4529,9 +3459,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001697", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz", - "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", + "version": "1.0.30001703", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz", + "integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==", "funding": [ { "type": "opencollective", @@ -4860,15 +3790,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4948,12 +3869,11 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -5134,18 +4054,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5268,9 +4176,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.93", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.93.tgz", - "integrity": "sha512-M+29jTcfNNoR9NV7la4SwUqzWAxEwnc7ThA5e1m6LRSotmpfpCpLcIfgtSCVL+MllNLgAyM/5ru86iMRemPzDQ==", + "version": "1.5.114", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz", + "integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==", "dev": true, "license": "ISC" }, @@ -5731,6 +4639,29 @@ } } }, + "node_modules/eslint-import-resolver-typescript/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "node_modules/eslint-module-utils": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", @@ -6329,6 +5260,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -8959,13 +7891,6 @@ "semver": "bin/semver.js" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, "node_modules/make-event-props": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz", @@ -9633,10 +8558,9 @@ } }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nan": { "version": "2.20.0", @@ -9645,9 +8569,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", "funding": [ { "type": "github", @@ -11652,10 +10576,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "engines": { "node": ">=0.10.0" } @@ -12135,138 +11058,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-jest": { - "version": "29.3.2", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.2.tgz", - "integrity": "sha512-bJJkrWc6PjFVz5g2DGCNUo8z7oFEYaz1xP1NpeDU7KNLMWPpEyV8Chbpkn8xjzgRDpQhnGMyvyldoL7h8JXyug==", - "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", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.1", - "type-fest": "^4.39.1", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "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", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "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==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ts-jest/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -12535,9 +11326,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -12591,15 +11382,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -13019,18 +11801,6 @@ "node": ">=8" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13068,6 +11838,96 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz", + "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz", + "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz", + "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz", + "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz", + "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz", + "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/package.json b/package.json index 9c58051b6..988ead11d 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", diff --git a/src/actions/ledger/ledgerAccount.ts b/src/actions/ledger/ledgerAccount.ts new file mode 100644 index 000000000..382ffbb88 --- /dev/null +++ b/src/actions/ledger/ledgerAccount.ts @@ -0,0 +1,8 @@ +"use server" + +import { action } from "@/actions/action"; +import { LedgerAccount } from "@/services/ledger/ledgerAccount/methods"; + +export const createLedgerAccount = action(LedgerAccount.create) +export const readLedgerAccount = action(LedgerAccount.read) +export const calculateLedgerAccountBalance = action(LedgerAccount.calculateBalance) \ No newline at end of file diff --git a/src/actions/ledger/transactions/deposits.ts b/src/actions/ledger/transactions/deposits.ts new file mode 100644 index 000000000..dde1a5932 --- /dev/null +++ b/src/actions/ledger/transactions/deposits.ts @@ -0,0 +1,6 @@ +"use server" + +import { action } from "@/actions/action"; +import { Deposits } from "@/services/ledger/transactions/deposits/methods"; + +export const createDeposit = action(Deposits.create) \ No newline at end of file diff --git a/src/actions/ledger/transactions/payouts.ts b/src/actions/ledger/transactions/payouts.ts new file mode 100644 index 000000000..baf9a53bd --- /dev/null +++ b/src/actions/ledger/transactions/payouts.ts @@ -0,0 +1,6 @@ +"use server" + +import { action } from "@/actions/action"; +import { Payouts } from "@/services/ledger/transactions/payouts/methods"; + +export const createPayout = action(Payouts.create) \ No newline at end of file diff --git a/src/app/_components/NavBar/UserNavigation.tsx b/src/app/_components/NavBar/UserNavigation.tsx index e6f70c252..1b1dfe74d 100644 --- a/src/app/_components/NavBar/UserNavigation.tsx +++ b/src/app/_components/NavBar/UserNavigation.tsx @@ -54,13 +54,13 @@ export default function UserNavigation({ profile }: PropTypes) {

OmegaId

- +

Konto

-

Instillinger

+

Innstillinger

diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx new file mode 100644 index 000000000..9f2b05699 --- /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!

+ +
+} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx index 89f071ec3..ef41e75e6 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, faCoins, faKey, faMoneyBill, faPaperPlane } from '@fortawesome/free-solid-svg-icons' import { usePathname } from 'next/navigation' type PropTypes = { @@ -32,6 +32,12 @@ export default function Nav({ username }: PropTypes) { > + + + +

Konto

+

Balanse: {balance} kluengede muent

+
+

Innskudd

+
+ + +
+

Utbetaling

+
+ + +
+

Transaksjoner

+ + + + + + + + + + {transactions.map((transaction, i) => ( + + + + + + ))} + +
DatoSumBeskrivese
{transaction.date}{transaction.amount.toFixed(2)}{transaction.description}
+

Se alle transaksjoner

+
+

Betalingsalternativer

+ + +

VIPPS (TODO)

+ +} \ No newline at end of file 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..330b1202a --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -0,0 +1,5 @@ +export default async function Transactions() { + return ( +

Her kommer alle transaksjonene dine!

+ ) +} \ No newline at end of file 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/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..d2511d0d4 --- /dev/null +++ b/src/prisma/schema/ledger.prisma @@ -0,0 +1,41 @@ +enum LedgerAccountType { + USER + GROUP +} + +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 + payoutAccountNumber String? // For display only + inTransactions Transaction[] @relation("TransactionToAccount") + outTransactions Transaction[] @relation("TransactionFromAccount") + // products Product[] +} + +enum TransactionType { + DEPOSIT + PURCHASE + REFUND + PAYOUT +} + +model Transaction { + id Int @id @default(autoincrement()) + amount Decimal + fromAccount LedgerAccount? @relation(fields: [fromAccountId], references: [id], name: "TransactionFromAccount", onDelete: Restrict, onUpdate: Cascade) + fromAccountId Int? + toAccount LedgerAccount? @relation(fields: [toAccountId], references: [id], name: "TransactionToAccount", onDelete: Restrict, onUpdate: Cascade) + toAccountId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + transactionType TransactionType + // deposit Deposit? + // purchases Purchase[] + // refunds Refund[] + // payout Payout? +} \ No newline at end of file diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index c31cd5548..173871cd1 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -27,6 +27,7 @@ model User { memberships Membership[] credentials Credentials? feideAccount FeideAccount? + ledgerAccount LedgerAccount? notificationSubscriptions NotificationSubscription[] mailingLists MailingListUser[] diff --git a/src/services/ledger/ledgerAccount/authers.ts b/src/services/ledger/ledgerAccount/authers.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts new file mode 100644 index 000000000..3ee87bd1a --- /dev/null +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -0,0 +1,124 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { createAccountValidation } from "./validation"; +import { ServerError } from "@/services/error"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; + +export namespace LedgerAccount { + /** + * 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({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + dataValidation: createAccountValidation, + 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({ + auther: () => 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.client(prisma).execute({ session, data: params }) + }, + }) + + /** + * Calculates the balance of an account. + * + * @param params.id The ID of the account to calculate the balance for. + * + * @returns The balance of the account. + */ + export const calculateBalance = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + }), + method: async ({ prisma, params }) => { + // Since we can't know if the account exists from the aggregate queries, we need to check it manually first. + const accountExists = await prisma.ledgerAccount.count({ + where: { + id: params.id, + }, + }) + + if (!accountExists) { + throw new ServerError('NOT FOUND', 'Kontoen eksisterer ikke.') + } + + const sumTransactions = async (where: Partial) => { + const result = await prisma.transaction.aggregate({ + _sum: { + amount: true, + }, + where, + }) + return result._sum.amount ?? new Prisma.Decimal(0) + } + + const [totalIn, totalOut] = await Promise.all([ + sumTransactions({ toAccountId: params.id }), + sumTransactions({ fromAccountId: params.id }), + ]) + + return totalIn.minus(totalOut) + } + }) +} \ No newline at end of file diff --git a/src/services/ledger/ledgerAccount/validation.ts b/src/services/ledger/ledgerAccount/validation.ts new file mode 100644 index 000000000..28258b57a --- /dev/null +++ b/src/services/ledger/ledgerAccount/validation.ts @@ -0,0 +1,35 @@ +import { ValidationBase } from '@/services/Validation' +import { z } from 'zod' +import type { ValidationTypes } from '@/services/Validation' + +export const baseAccountValidation = new ValidationBase({ + type: { + userId: z.number().optional(), + groupId: z.number().optional(), + payoutAccountNumber: z.string().optional(), + }, + details: { + userId: z.number().optional(), + groupId: z.number().optional(), + payoutAccountNumber: z.string().optional(), + }, +}) + +export const createAccountValidation = baseAccountValidation.createValidation({ + keys: ['userId', 'groupId', 'payoutAccountNumber'], + transformer: data => data, + refiner: { + // Only one of userId and groupId can be set + fcn: data => (data.userId === undefined) !== (data.groupId === undefined), + message: 'Bruker- eller gruppe-ID må være satt.', + }, +}) + +export type CreateAccountTypes = ValidationTypes + +export const updateAccountValidation = baseAccountValidation.createValidation({ + keys: ['payoutAccountNumber'], + transformer: data => data, +}) + +export type UpdateAccountTypes = ValidationTypes diff --git a/src/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts new file mode 100644 index 000000000..6e4a07b6f --- /dev/null +++ b/src/services/ledger/transactions/deposits/methods.ts @@ -0,0 +1,23 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { z } from "zod"; +import { createDepositValidation } from "./validation"; + +export namespace Deposits { + export const create = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + accountId: z.number(), + }), + dataValidation: createDepositValidation, + method: async ({ prisma, params, data }) => { + return prisma.transaction.create({ + data: { + transactionType: 'DEPOSIT', + toAccountId: params.accountId, + amount: data.amount, + } + }) + }, + }) +} \ No newline at end of file diff --git a/src/services/ledger/transactions/deposits/validation.ts b/src/services/ledger/transactions/deposits/validation.ts new file mode 100644 index 000000000..aeb5009ef --- /dev/null +++ b/src/services/ledger/transactions/deposits/validation.ts @@ -0,0 +1,19 @@ +import { ValidationBase } from '@/services/Validation' +import { z } from 'zod' +import type { ValidationTypes } from '@/services/Validation' + +const baseDepositValidation = new ValidationBase({ + details: { + amount: z.coerce.number().int().positive(), + }, + type: { + amount: z.coerce.number(), + } +}) + +export const createDepositValidation = baseDepositValidation.createValidation({ + keys: ['amount'], + transformer: data => data, +}) + +export type CreateDepositTypes = ValidationTypes diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts new file mode 100644 index 000000000..507bb27c3 --- /dev/null +++ b/src/services/ledger/transactions/methods.ts @@ -0,0 +1,6 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; + +export namespace Transactions { + +} diff --git a/src/services/ledger/transactions/payouts/methods.ts b/src/services/ledger/transactions/payouts/methods.ts new file mode 100644 index 000000000..c3cf19b35 --- /dev/null +++ b/src/services/ledger/transactions/payouts/methods.ts @@ -0,0 +1,23 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { z } from "zod"; +import { createPayoutValidation } from "./validation"; + +export namespace Payouts { + export const create = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + accountId: z.number(), + }), + dataValidation: createPayoutValidation, + method: async ({ prisma, params, data }) => { + return prisma.transaction.create({ + data: { + transactionType: 'PAYOUT', + fromAccountId: params.accountId, + amount: data.amount, + } + }) + }, + }) +} \ No newline at end of file diff --git a/src/services/ledger/transactions/payouts/validation.ts b/src/services/ledger/transactions/payouts/validation.ts new file mode 100644 index 000000000..ea70b3a3c --- /dev/null +++ b/src/services/ledger/transactions/payouts/validation.ts @@ -0,0 +1,19 @@ +import { ValidationBase } from '@/services/Validation' +import { z } from 'zod' +import type { ValidationTypes } from '@/services/Validation' + +const basePayoutValidation = new ValidationBase({ + details: { + amount: z.coerce.number().int().positive(), + }, + type: { + amount: z.coerce.number(), + } +}) + +export const createPayoutValidation = basePayoutValidation.createValidation({ + keys: ['amount'], + transformer: data => data, +}) + +export type CreatePayoutTypes = ValidationTypes From 9716f0a2d799be2afb01ba12dcf2063e57d8fdcd Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 11 Mar 2025 23:47:41 +0100 Subject: [PATCH 02/62] feat: payments --- src/actions/ledger/ledgerAccount.ts | 8 ++-- src/actions/ledger/transactions/deposits.ts | 4 +- src/actions/ledger/transactions/payments.ts | 6 +++ src/actions/ledger/transactions/payouts.ts | 4 +- .../[username]/(user-admin)/account/page.tsx | 18 +++++--- src/prisma/schema/ledger.prisma | 4 +- src/services/ledger/ledgerAccount/methods.ts | 6 +-- .../ledger/transactions/deposits/methods.ts | 2 +- src/services/ledger/transactions/methods.ts | 6 --- .../ledger/transactions/payment/methods.ts | 44 +++++++++++++++++++ .../ledger/transactions/payment/validation.ts | 21 +++++++++ .../ledger/transactions/payouts/methods.ts | 30 ++++++++++--- .../ledger/transactions/validation.ts | 0 13 files changed, 121 insertions(+), 32 deletions(-) create mode 100644 src/actions/ledger/transactions/payments.ts create mode 100644 src/services/ledger/transactions/payment/methods.ts create mode 100644 src/services/ledger/transactions/payment/validation.ts create mode 100644 src/services/ledger/transactions/validation.ts diff --git a/src/actions/ledger/ledgerAccount.ts b/src/actions/ledger/ledgerAccount.ts index 382ffbb88..74d02d52f 100644 --- a/src/actions/ledger/ledgerAccount.ts +++ b/src/actions/ledger/ledgerAccount.ts @@ -1,8 +1,8 @@ "use server" import { action } from "@/actions/action"; -import { LedgerAccount } from "@/services/ledger/ledgerAccount/methods"; +import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; -export const createLedgerAccount = action(LedgerAccount.create) -export const readLedgerAccount = action(LedgerAccount.read) -export const calculateLedgerAccountBalance = action(LedgerAccount.calculateBalance) \ No newline at end of file +export const createLedgerAccount = action(LedgerAccountMethods.create) +export const readLedgerAccount = action(LedgerAccountMethods.read) +export const calculateLedgerAccountBalance = action(LedgerAccountMethods.calculateBalance) \ No newline at end of file diff --git a/src/actions/ledger/transactions/deposits.ts b/src/actions/ledger/transactions/deposits.ts index dde1a5932..3319ccd73 100644 --- a/src/actions/ledger/transactions/deposits.ts +++ b/src/actions/ledger/transactions/deposits.ts @@ -1,6 +1,6 @@ "use server" import { action } from "@/actions/action"; -import { Deposits } from "@/services/ledger/transactions/deposits/methods"; +import { DepositMethods } from "@/services/ledger/transactions/deposits/methods"; -export const createDeposit = action(Deposits.create) \ No newline at end of file +export const createDeposit = action(DepositMethods.create) \ No newline at end of file diff --git a/src/actions/ledger/transactions/payments.ts b/src/actions/ledger/transactions/payments.ts new file mode 100644 index 000000000..50605b789 --- /dev/null +++ b/src/actions/ledger/transactions/payments.ts @@ -0,0 +1,6 @@ +"use server" + +import { action } from "@/actions/action" +import { PaymentMethods } from "@/services/ledger/transactions/payment/methods" + +export const createPayment = action(PaymentMethods.create) \ No newline at end of file diff --git a/src/actions/ledger/transactions/payouts.ts b/src/actions/ledger/transactions/payouts.ts index baf9a53bd..c18f6ff17 100644 --- a/src/actions/ledger/transactions/payouts.ts +++ b/src/actions/ledger/transactions/payouts.ts @@ -1,6 +1,6 @@ "use server" import { action } from "@/actions/action"; -import { Payouts } from "@/services/ledger/transactions/payouts/methods"; +import { PayoutMethods } from "@/services/ledger/transactions/payouts/methods"; -export const createPayout = action(Payouts.create) \ No newline at end of file +export const createPayout = action(PayoutMethods.create) \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 077dd4a79..b612a6d64 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,8 +1,10 @@ import { bindParams } from "@/actions/bind" import { calculateLedgerAccountBalance, readLedgerAccount } from "@/actions/ledger/ledgerAccount" import { createDeposit } from "@/actions/ledger/transactions/deposits" +import { createPayment } from "@/actions/ledger/transactions/payments" import { createPayout } from "@/actions/ledger/transactions/payouts" import Form from "@/app/_components/Form/Form" +import NumberInput from "@/app/_components/UI/NumberInput" import TextInput from "@/app/_components/UI/TextInput" import { unwrapActionReturn } from "@/app/redirectToErrorPage" import { getUser } from "@/auth/getUser" @@ -39,16 +41,22 @@ export default async function Account() { return

Konto

-

Balanse: {balance} kluengede muent

+

Balanse: {balance.toFixed(2)} kluengede muent


Innskudd

-
- + + + +
+

Betaling

+
+ +

Utbetaling

-
- + +

Transaksjoner

diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index d2511d0d4..ce81e1d14 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -18,14 +18,14 @@ model LedgerAccount { enum TransactionType { DEPOSIT - PURCHASE + PAYMENT REFUND PAYOUT } model Transaction { id Int @id @default(autoincrement()) - amount Decimal + amount Int // In øre fromAccount LedgerAccount? @relation(fields: [fromAccountId], references: [id], name: "TransactionFromAccount", onDelete: Restrict, onUpdate: Cascade) fromAccountId Int? toAccount LedgerAccount? @relation(fields: [toAccountId], references: [id], name: "TransactionToAccount", onDelete: Restrict, onUpdate: Cascade) diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 3ee87bd1a..c88e4065b 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -5,7 +5,7 @@ import { ServerError } from "@/services/error"; import { z } from "zod"; import { Prisma } from "@prisma/client"; -export namespace LedgerAccount { +export namespace LedgerAccountMethods { /** * Creates a new ledger account for given user or group. * @@ -110,7 +110,7 @@ export namespace LedgerAccount { }, where, }) - return result._sum.amount ?? new Prisma.Decimal(0) + return result._sum.amount ?? 0 } const [totalIn, totalOut] = await Promise.all([ @@ -118,7 +118,7 @@ export namespace LedgerAccount { sumTransactions({ fromAccountId: params.id }), ]) - return totalIn.minus(totalOut) + return (totalIn - totalOut) / 100 } }) } \ No newline at end of file diff --git a/src/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts index 6e4a07b6f..bbbee7a6d 100644 --- a/src/services/ledger/transactions/deposits/methods.ts +++ b/src/services/ledger/transactions/deposits/methods.ts @@ -3,7 +3,7 @@ import { ServiceMethod } from "@/services/ServiceMethod"; import { z } from "zod"; import { createDepositValidation } from "./validation"; -export namespace Deposits { +export namespace DepositMethods { export const create = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts index 507bb27c3..e69de29bb 100644 --- a/src/services/ledger/transactions/methods.ts +++ b/src/services/ledger/transactions/methods.ts @@ -1,6 +0,0 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; - -export namespace Transactions { - -} diff --git a/src/services/ledger/transactions/payment/methods.ts b/src/services/ledger/transactions/payment/methods.ts new file mode 100644 index 000000000..83515c79b --- /dev/null +++ b/src/services/ledger/transactions/payment/methods.ts @@ -0,0 +1,44 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { z } from "zod"; +import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; +import { ServerError } from "@/services/error"; +import { createPaymentValidation } from "./validation"; + +export namespace PaymentMethods { + export const create = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + fromAccountId: z.number(), + }), + dataValidation: createPaymentValidation, + opensTransaction: true, + method: async ({ prisma, session, params, data }) => { + if (params.fromAccountId === data.toAccountId) { + throw new ServerError('BAD DATA', 'Overføring til samme konto er ikke tillat.') + } + + return prisma.$transaction(async (tx) => { + await tx.transaction.create({ + data: { + transactionType: 'PAYMENT', + fromAccountId: params.fromAccountId, + toAccountId: data.toAccountId, + amount: data.amount, + }, + }) + + const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ + params: { + id: params.fromAccountId, + }, + session, + }) + + if (newBalancee < 0) { + throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') + } + }) + }, + }) +} \ No newline at end of file diff --git a/src/services/ledger/transactions/payment/validation.ts b/src/services/ledger/transactions/payment/validation.ts new file mode 100644 index 000000000..dad136665 --- /dev/null +++ b/src/services/ledger/transactions/payment/validation.ts @@ -0,0 +1,21 @@ +import { ValidationBase } from '@/services/Validation' +import { z } from 'zod' +import type { ValidationTypes } from '@/services/Validation' + +const basePaymentValidation = new ValidationBase({ + details: { + amount: z.coerce.number().int().positive(), + toAccountId: z.coerce.number().int(), + }, + type: { + amount: z.coerce.number(), + toAccountId: z.coerce.number(), + } +}) + +export const createPaymentValidation = basePaymentValidation.createValidation({ + keys: ['amount', 'toAccountId'], + transformer: data => data, +}) + +export type CreatePaymentTypes = ValidationTypes diff --git a/src/services/ledger/transactions/payouts/methods.ts b/src/services/ledger/transactions/payouts/methods.ts index c3cf19b35..96b968524 100644 --- a/src/services/ledger/transactions/payouts/methods.ts +++ b/src/services/ledger/transactions/payouts/methods.ts @@ -2,20 +2,36 @@ import { RequireNothing } from "@/auth/auther/RequireNothing"; import { ServiceMethod } from "@/services/ServiceMethod"; import { z } from "zod"; import { createPayoutValidation } from "./validation"; +import { LedgerAccountMethods } from "../../ledgerAccount/methods"; +import { ServerError } from "@/services/error"; -export namespace Payouts { +export namespace PayoutMethods { export const create = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ accountId: z.number(), }), dataValidation: createPayoutValidation, - method: async ({ prisma, params, data }) => { - return prisma.transaction.create({ - data: { - transactionType: 'PAYOUT', - fromAccountId: params.accountId, - amount: data.amount, + opensTransaction: true, + method: async ({ prisma, session, params, data }) => { + return prisma.$transaction(async (tx) => { + const payout = await tx.transaction.create({ + data: { + transactionType: 'PAYOUT', + fromAccountId: params.accountId, + amount: data.amount, + } + }) + + const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ + params: { + id: params.accountId, + }, + session, + }) + + if (newBalancee < 0) { + throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') } }) }, diff --git a/src/services/ledger/transactions/validation.ts b/src/services/ledger/transactions/validation.ts new file mode 100644 index 000000000..e69de29bb From fdc90c07db85c5dcba2b5c7c84d9cc89a30b853b Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 12 Mar 2025 01:45:55 +0100 Subject: [PATCH 03/62] feat: transaction paging --- .../ledger/transactions/transactions.ts | 6 +++ .../account/transactions/page.tsx | 51 +++++++++++++++++-- src/contexts/paging/TranasctionPaging.tsx | 24 +++++++++ .../seeder/src/development/seedDevUsers.ts | 2 +- src/services/ledger/transactions/methods.ts | 34 +++++++++++++ 5 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/actions/ledger/transactions/transactions.ts create mode 100644 src/contexts/paging/TranasctionPaging.tsx diff --git a/src/actions/ledger/transactions/transactions.ts b/src/actions/ledger/transactions/transactions.ts new file mode 100644 index 000000000..2795f7108 --- /dev/null +++ b/src/actions/ledger/transactions/transactions.ts @@ -0,0 +1,6 @@ +"use server" + +import { action } from "@/actions/action" +import { TransactionMethods } from "@/services/ledger/transactions/methods" + +export const readTransactionsPage = action(TransactionMethods.readPage) \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index 330b1202a..2900d110c 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,5 +1,50 @@ -export default async function Transactions() { +"use client" + +import { readLedgerAccount } from "@/actions/ledger/ledgerAccount" +import EndlessScroll from "@/app/_components/PagingWrappers/EndlessScroll" +import { getUser } from "@/auth/getUser" +import { useUser } from "@/auth/useUser" +import TransactionPagingProvider, { TransactionPagingContext } from "@/contexts/paging/TranasctionPaging" +import { useContext, useEffect, useState } from "react" + +export default function Transactions() { + // const transactionPagingContext = useContext(TransactionPagingContext) + + // if (!transactionPagingContext) { + // throw new Error('fuck') + // } + + const accountId = 2 + return ( -

Her kommer alle transaksjonene dine!

- ) + + + + + + + + + + + + + + + } + /> + {/* { + transactionPagingContext?.state.data.map((transaction) => + + + + + + ) + } */} + +
DatoSumBeskrivelse
{transaction.createdAt.toLocaleString()}{(transaction.toAccountId === accountId ? transaction.amount : -transaction.amount).toFixed(2)}{transaction.transactionType}
{transaction.createdAt.toDateString()}{transaction.amount}{transaction.transactionType}
+ ) } \ No newline at end of file diff --git a/src/contexts/paging/TranasctionPaging.tsx b/src/contexts/paging/TranasctionPaging.tsx new file mode 100644 index 000000000..fa2d68619 --- /dev/null +++ b/src/contexts/paging/TranasctionPaging.tsx @@ -0,0 +1,24 @@ +'use client' + +import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' +import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import type { ReadPageInput } from '@/lib/paging/Types' +import { Transaction } from '@prisma/client' + +export type PageSizeTransactions = 10 +const fetcher = async (paging: ReadPageInput) => { + return readTransactionsPage({ paging }) +} + +export const TransactionPagingContext = generatePagingContext< + Transaction, + { id: number }, + PageSizeTransactions, + { accountId: number } +>() +const TransactionPagingProvider = generatePagingProvider({ + Context: TransactionPagingContext, + fetcher, + getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), +}) +export default TransactionPagingProvider diff --git a/src/prisma/seeder/src/development/seedDevUsers.ts b/src/prisma/seeder/src/development/seedDevUsers.ts index 090f82199..67177fcce 100644 --- a/src/prisma/seeder/src/development/seedDevUsers.ts +++ b/src/prisma/seeder/src/development/seedDevUsers.ts @@ -203,7 +203,7 @@ export default async function seedDevUsers(prisma: PrismaClient) { studentCard: 'vever', credentials: { create: { - passwordHash: 'password', + passwordHash, }, }, emailVerified: new Date(), diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts index e69de29bb..93817afc6 100644 --- a/src/services/ledger/transactions/methods.ts +++ b/src/services/ledger/transactions/methods.ts @@ -0,0 +1,34 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { cursorPageingSelection } from "@/lib/paging/cursorPageingSelection"; +import { readPageInputSchemaObject } from "@/lib/paging/schema"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { z } from "zod"; + +export namespace TransactionMethods { + export const readPage = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: readPageInputSchemaObject( + z.number(), + z.object({ + id: z.number(), + }), + z.object({ + accountId: z.number(), + }), + ), + method: async ({ prisma, params }) => { + return prisma.transaction.findMany({ + where: { + OR: [ + { fromAccountId: params.paging.details.accountId }, + { toAccountId: params.paging.details.accountId }, + ] + }, + orderBy: { + createdAt: 'desc', + }, + ...cursorPageingSelection(params.paging.page) + }) + } + }) +} \ No newline at end of file From a545516b6c18127d009b91d3c8b86a969e7f0dfd Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 13 Mar 2025 11:21:23 +0100 Subject: [PATCH 04/62] refactor: rename lib/money to lib/currency --- package-lock.json | 524 ++++++++++-------- .../admin/cabin-product/[product]/page.tsx | 4 +- src/app/admin/product/[productId]/page.tsx | 4 +- .../shop/[shop]/EditProductForShopForm.tsx | 4 +- src/app/admin/shop/[shop]/page.tsx | 4 +- src/app/cabin/book/CabinPriceCalculator.tsx | 8 +- src/lib/currency/config.ts | 1 + src/lib/currency/convert.ts | 15 + src/lib/money/ConfigVars.ts | 3 - src/lib/money/convert.ts | 16 - src/services/cabin/product/schemas.ts | 4 +- src/services/shop/product/schemas.ts | 4 +- 12 files changed, 334 insertions(+), 257 deletions(-) create mode 100644 src/lib/currency/config.ts create mode 100644 src/lib/currency/convert.ts delete mode 100644 src/lib/money/ConfigVars.ts delete mode 100644 src/lib/money/convert.ts diff --git a/package-lock.json b/package-lock.json index 84572b7b4..b29b992bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,16 +161,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "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.26.9", - "@babel/types": "^7.26.9", - "@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": { @@ -266,6 +266,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", @@ -412,13 +422,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "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.26.9" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -735,53 +745,43 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "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.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "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.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@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.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "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": { @@ -817,6 +817,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "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": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1568,18 +1578,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": { @@ -1592,16 +1598,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", @@ -1610,9 +1606,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": { @@ -1700,6 +1696,70 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", + "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", + "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", + "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", + "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@next/swc-linux-x64-gnu": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.1.tgz", @@ -1732,6 +1792,38 @@ "node": ">= 10" } }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", + "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", + "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4159,22 +4251,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.114", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz", @@ -5116,39 +5192,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", @@ -5504,6 +5547,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", @@ -6537,25 +6602,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", @@ -7891,6 +7937,13 @@ "semver": "bin/semver.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/make-event-props": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz", @@ -8591,6 +8644,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", @@ -10367,9 +10427,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" @@ -11058,6 +11118,95 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "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", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@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": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "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": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -11223,6 +11372,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", @@ -11653,6 +11816,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", @@ -11838,96 +12008,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz", - "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz", - "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz", - "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz", - "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz", - "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz", - "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } 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/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/lib/currency/config.ts b/src/lib/currency/config.ts new file mode 100644 index 000000000..bf796c17e --- /dev/null +++ b/src/lib/currency/config.ts @@ -0,0 +1 @@ +export const currencySymbol = 'Klinguende Meunt' \ No newline at end of file diff --git a/src/lib/currency/convert.ts b/src/lib/currency/convert.ts new file mode 100644 index 000000000..d40c0a591 --- /dev/null +++ b/src/lib/currency/convert.ts @@ -0,0 +1,15 @@ +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 { + return Math.floor(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/ConfigVars.ts b/src/lib/money/ConfigVars.ts deleted file mode 100644 index c7006bf02..000000000 --- a/src/lib/money/ConfigVars.ts +++ /dev/null @@ -1,3 +0,0 @@ - - -export const currencySymbol = 'Klinguende Meunt' 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/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/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(), From 25e94f8ad4d69d792a53aa9ca5d0c85edb21b380 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 13 Mar 2025 18:22:18 +0100 Subject: [PATCH 05/62] feat: money stuff --- .env.default | 5 + docker-compose.base.yml | 4 +- package-lock.json | 501 ++++++++++++------ package.json | 1 + src/actions/ledger/transactions/deposits.ts | 2 +- .../Ledger/LedgerAccountBalance.tsx | 17 + .../TransactionList/TransactionList.tsx | 21 + .../TransactionRow.module.scss | 12 + .../Ledger/TransactionList/TransactionRow.tsx | 18 + src/app/_components/Stripe/DepositForm.tsx | 27 + src/app/_components/Stripe/PaymentForm.tsx | 55 ++ .../_components/Stripe/PaymentProvider.tsx | 28 + src/app/api/stripe-event/route.ts | 47 ++ .../[username]/(user-admin)/account/page.tsx | 51 +- .../account/transactions/page.tsx | 49 +- src/lib/stripe.ts | 7 + src/prisma/schema/ledger.prisma | 72 ++- src/services/ledger/ledgerAccount/methods.ts | 18 +- src/services/ledger/stripeEvent/methods.ts | 11 + src/services/ledger/stripeEvent/validation.ts | 2 + .../ledger/transactions/deposits/config.ts | 1 + .../ledger/transactions/deposits/methods.ts | 35 +- .../transactions/deposits/validation.ts | 2 +- src/services/ledger/transactions/methods.ts | 4 + .../ledger/transactions/payment/methods.ts | 9 +- .../ledger/transactions/payouts/methods.ts | 23 +- tests/services/ledger/deposits.test.ts | 7 + tests/services/ledger/ledgerAccounts.test.ts | 7 + tests/services/ledger/payments.test.ts | 7 + tests/services/ledger/payouts.test.ts | 7 + tests/services/ledger/transactions.test.ts | 7 + 31 files changed, 781 insertions(+), 276 deletions(-) create mode 100644 src/app/_components/Ledger/LedgerAccountBalance.tsx create mode 100644 src/app/_components/Ledger/TransactionList/TransactionList.tsx create mode 100644 src/app/_components/Ledger/TransactionList/TransactionRow.module.scss create mode 100644 src/app/_components/Ledger/TransactionList/TransactionRow.tsx create mode 100644 src/app/_components/Stripe/DepositForm.tsx create mode 100644 src/app/_components/Stripe/PaymentForm.tsx create mode 100644 src/app/_components/Stripe/PaymentProvider.tsx create mode 100644 src/app/api/stripe-event/route.ts create mode 100644 src/lib/stripe.ts create mode 100644 src/services/ledger/stripeEvent/methods.ts create mode 100644 src/services/ledger/stripeEvent/validation.ts create mode 100644 src/services/ledger/transactions/deposits/config.ts create mode 100644 tests/services/ledger/deposits.test.ts create mode 100644 tests/services/ledger/ledgerAccounts.test.ts create mode 100644 tests/services/ledger/payments.test.ts create mode 100644 tests/services/ledger/payouts.test.ts create mode 100644 tests/services/ledger/transactions.test.ts 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/package-lock.json b/package-lock.json index b29b992bb..b7334a31d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,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", @@ -807,6 +808,30 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", @@ -1697,13 +1722,12 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", - "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz", + "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1713,13 +1737,12 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", - "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz", + "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -1729,13 +1752,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", - "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz", + "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1745,13 +1767,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", - "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz", + "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -1793,13 +1814,12 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", - "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz", + "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -1809,13 +1829,12 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", - "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz", + "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -2408,6 +2427,34 @@ "tslib": "^2.8.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2619,7 +2666,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" } @@ -2943,6 +2989,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3052,6 +3111,13 @@ "node": ">=10" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3533,6 +3599,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", @@ -3882,6 +3977,13 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4146,6 +4248,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4224,6 +4336,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", @@ -4384,13 +4510,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" } @@ -4399,7 +4522,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" } @@ -4450,10 +4572,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" }, @@ -5297,26 +5419,10 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "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" } @@ -5391,16 +5497,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" @@ -5419,6 +5530,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", @@ -5524,12 +5648,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" @@ -5612,10 +5736,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" }, @@ -5647,7 +5771,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" }, @@ -7974,6 +8097,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", @@ -9278,10 +9410,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" }, @@ -9941,6 +10073,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", @@ -10540,15 +10687,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" @@ -10945,6 +11146,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", @@ -11118,95 +11332,50 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-jest": { - "version": "29.4.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz", - "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==", + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.2", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" }, "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" }, "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@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" + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" }, "peerDependenciesMeta": { - "@babel/core": { + "@swc/core": { "optional": true }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { + "@swc/wasm": { "optional": true } } }, - "node_modules/ts-jest/node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "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": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ts-jest/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -11404,8 +11573,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", @@ -11545,6 +11713,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -11971,6 +12146,16 @@ "node": ">=8" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 988ead11d..4161dab68 100644 --- a/package.json +++ b/package.json @@ -57,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/actions/ledger/transactions/deposits.ts b/src/actions/ledger/transactions/deposits.ts index 3319ccd73..e6318a58c 100644 --- a/src/actions/ledger/transactions/deposits.ts +++ b/src/actions/ledger/transactions/deposits.ts @@ -3,4 +3,4 @@ import { action } from "@/actions/action"; import { DepositMethods } from "@/services/ledger/transactions/deposits/methods"; -export const createDeposit = action(DepositMethods.create) \ No newline at end of file +export const createStripeDeposit = action(DepositMethods.createStripe) \ No newline at end of file diff --git a/src/app/_components/Ledger/LedgerAccountBalance.tsx b/src/app/_components/Ledger/LedgerAccountBalance.tsx new file mode 100644 index 000000000..25d328a2c --- /dev/null +++ b/src/app/_components/Ledger/LedgerAccountBalance.tsx @@ -0,0 +1,17 @@ +import { calculateLedgerAccountBalance } from "@/actions/ledger/ledgerAccount" +import { unwrapActionReturn } from "@/app/redirectToErrorPage" +import { displayAmount } from "@/lib/currency/convert" + +type Props = { + accountId: number, + showFees?: boolean, +} + +export default async function LedgerAccountBalance({ accountId, showFees }: Props) { + const balance = unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) + + return
+

Balanse: {displayAmount(balance.total)} Kluengende muent

+ {showFees &&

Avgifter: {displayAmount(balance.fees)} Kluengende muent

} +
+} \ No newline at end of file diff --git a/src/app/_components/Ledger/TransactionList/TransactionList.tsx b/src/app/_components/Ledger/TransactionList/TransactionList.tsx new file mode 100644 index 000000000..08ea16d2d --- /dev/null +++ b/src/app/_components/Ledger/TransactionList/TransactionList.tsx @@ -0,0 +1,21 @@ +"use client" + +import TransactionPagingProvider, { TransactionPagingContext } from "@/contexts/paging/TranasctionPaging"; +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import TransactionRow from "./TransactionRow"; + +type Props = { + accountId: number, + showFees?: boolean, +} + +export default function TransactionList({ accountId, showFees }: Props) { + return + + } + /> + +} \ No newline at end of file diff --git a/src/app/_components/Ledger/TransactionList/TransactionRow.module.scss b/src/app/_components/Ledger/TransactionList/TransactionRow.module.scss new file mode 100644 index 000000000..aab02cce2 --- /dev/null +++ b/src/app/_components/Ledger/TransactionList/TransactionRow.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/TransactionList/TransactionRow.tsx b/src/app/_components/Ledger/TransactionList/TransactionRow.tsx new file mode 100644 index 000000000..77195035e --- /dev/null +++ b/src/app/_components/Ledger/TransactionList/TransactionRow.tsx @@ -0,0 +1,18 @@ +import { displayAmount } from "@/lib/currency/convert" +import { Transaction } from "@prisma/client" +import styles from './TransactionRow.module.scss' + +type Props = { + transaction: Transaction, + showFees?: boolean, +} + +export default function TransactionRow({ transaction, showFees }: Props) { + return +

{transaction.createdAt.toLocaleString()}

+

{displayAmount((transaction.amount))}

+ {showFees &&

{transaction.fee ? displayAmount(transaction.fee) : '-'}

} +

{transaction.type}

+

{transaction.status}

+
+} \ No newline at end of file diff --git a/src/app/_components/Stripe/DepositForm.tsx b/src/app/_components/Stripe/DepositForm.tsx new file mode 100644 index 000000000..9d4c070ee --- /dev/null +++ b/src/app/_components/Stripe/DepositForm.tsx @@ -0,0 +1,27 @@ +"use client" + +import { ChangeEvent, useState } from "react" +import PaymentProvider from "./PaymentProvider" +import PaymentForm from "./PaymentForm" +import NumberInput from "../UI/NumberInput" + +type Props = { + accountId: number, +} + +export default function DepositForm({ accountId }: Props) { + const [depositAmount, setDepositAmount] = useState() + + const onChange = (event: ChangeEvent) => { + setDepositAmount(Number(event.target.value) * 100) + } + + return
+
+ + + + + +
+} \ No newline at end of file diff --git a/src/app/_components/Stripe/PaymentForm.tsx b/src/app/_components/Stripe/PaymentForm.tsx new file mode 100644 index 000000000..015a68a4b --- /dev/null +++ b/src/app/_components/Stripe/PaymentForm.tsx @@ -0,0 +1,55 @@ +"use client" + +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import Form from '@/components/Form/Form'; +import { createStripeDeposit } from '@/actions/ledger/transactions/deposits'; +import { createActionError } from '@/actions/error'; + +type Props = { + accountId: number, + children?: React.ReactNode, +} + +export default function PaymentForm({ accountId, children }: Props) { + const stripe = useStripe() + const elements = useElements() + + const handleSubmit = async (formData: FormData) => { + if (!stripe || !elements) { + return createActionError('BAD DATA') + } + + const { error: submitError } = await elements.submit() + if (submitError) { + return createActionError('BAD DATA', '') + } + + const deposit = await createStripeDeposit({ accountId }, formData) + + if (!deposit.success) { + return deposit + } + + const { error: confirmationError } = await stripe.confirmPayment({ + clientSecret: deposit.data.clientSecret, + elements, + confirmParams: { + return_url: window.location.href, + }, + // redirect: 'if_required', + }) + + if (confirmationError) { + return createActionError('UNKNOWN ERROR', confirmationError.message) + } + + return { + success: true, + } as const + } + + return
+ {children} + + +} \ No newline at end of file diff --git a/src/app/_components/Stripe/PaymentProvider.tsx b/src/app/_components/Stripe/PaymentProvider.tsx new file mode 100644 index 000000000..a8c521378 --- /dev/null +++ b/src/app/_components/Stripe/PaymentProvider.tsx @@ -0,0 +1,28 @@ +"use client" + +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 = { + amount: number, + children?: ReactNode +} + +export default function PaymentProvider({ children, amount }: Props) { + return <>{ + + {children} + + } +} \ No newline at end of file diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts new file mode 100644 index 000000000..4303706f8 --- /dev/null +++ b/src/app/api/stripe-event/route.ts @@ -0,0 +1,47 @@ +import logger from "@/lib/logger"; +import { stripe } from "@/lib/stripe" + +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) + + if (event.type != 'charge.succeeded' && event.type != 'charge.updated') { + logger.warn(`Unhandled Stripe event received: ${event.type}`) + return new Response('', { status: 200 }) + } + + if (typeof event.data.object.balance_transaction !== 'string' || typeof event.data.object.payment_intent !== 'string') { + return new Response('', { status: 200 }) + } + + const balanceTransaction = await stripe.balanceTransactions.retrieve(event.data.object.balance_transaction) + + const { transactionId } = await prisma.stripeDeposit.findUniqueOrThrow({ + where: { + paymentIntentId: event.data.object.payment_intent, + } + }) + + await prisma.transaction.update({ + where: { + id: transactionId, + }, + data: { + status: 'SUCCEEDED', + fee: balanceTransaction.fee, + } + }) + + return new Response('', { status: 200 }) +} + \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index b612a6d64..4ff26bb4e 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,9 +1,10 @@ import { bindParams } from "@/actions/bind" import { calculateLedgerAccountBalance, readLedgerAccount } from "@/actions/ledger/ledgerAccount" -import { createDeposit } from "@/actions/ledger/transactions/deposits" import { createPayment } from "@/actions/ledger/transactions/payments" import { createPayout } from "@/actions/ledger/transactions/payouts" import Form from "@/app/_components/Form/Form" +import LedgerAccountBalance from "@/app/_components/Ledger/LedgerAccountBalance" +import DepositForm from "@/app/_components/Stripe/DepositForm" import NumberInput from "@/app/_components/UI/NumberInput" import TextInput from "@/app/_components/UI/TextInput" import { unwrapActionReturn } from "@/app/redirectToErrorPage" @@ -12,41 +13,19 @@ import Button from "@/components/UI/Button" import Link from "next/link" export default async function Account() { - const transactions = [ - { - date: '1919-10-10 10:21', - amount: 100.00, - description: 'Innskudd' - }, - { - date: '1919-10-10 10:10', - amount: -3.00, - description: 'Kjøp ved Kioleskabet', - }, - { - date: '1919-10-10 10:29', - amount: -100.00, - description: 'Betaling for Vårphaest', - }, - ] - const session = await getUser({ userRequired: true, shouldRedirect: true, }) // TODO: Replace - const account = unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) - const balance = unwrapActionReturn(await calculateLedgerAccountBalance({ id: account?.id })) - + return

Konto

-

Balanse: {balance.toFixed(2)} kluengede muent

+

Innskudd

-
- - +

Betaling

@@ -60,25 +39,7 @@ export default async function Account() {

Transaksjoner

- - - - - - - - - - {transactions.map((transaction, i) => ( - - - - - - ))} - -
DatoSumBeskrivese
{transaction.date}{transaction.amount.toFixed(2)}{transaction.description}
-

Se alle transaksjoner

+

Se alle transaksjoner ->


Betalingsalternativer

diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index 2900d110c..c4585e79b 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,50 +1,21 @@ -"use client" - import { readLedgerAccount } from "@/actions/ledger/ledgerAccount" -import EndlessScroll from "@/app/_components/PagingWrappers/EndlessScroll" +import TransactionList from "@/app/_components/Ledger/TransactionList/TransactionList"; +import { unwrapActionReturn } from "@/app/redirectToErrorPage" import { getUser } from "@/auth/getUser" -import { useUser } from "@/auth/useUser" -import TransactionPagingProvider, { TransactionPagingContext } from "@/contexts/paging/TranasctionPaging" -import { useContext, useEffect, useState } from "react" -export default function Transactions() { +export default async function Transactions() { // const transactionPagingContext = useContext(TransactionPagingContext) // if (!transactionPagingContext) { // throw new Error('fuck') // } - const accountId = 2 + const { user } = await getUser({ + userRequired: true, + shouldRedirect: true, + }) + + const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })); - return ( - - - - - - - - - - - - - - - } - /> - {/* { - transactionPagingContext?.state.data.map((transaction) => - - - - - - ) - } */} - -
DatoSumBeskrivelse
{transaction.createdAt.toLocaleString()}{(transaction.toAccountId === accountId ? transaction.amount : -transaction.amount).toFixed(2)}{transaction.transactionType}
{transaction.createdAt.toDateString()}{transaction.amount}{transaction.transactionType}
- ) + return } \ No newline at end of file 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/ledger.prisma b/src/prisma/schema/ledger.prisma index ce81e1d14..37c0b3a63 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -4,18 +4,24 @@ enum LedgerAccountType { } 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 + 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 payoutAccountNumber String? // For display only - inTransactions Transaction[] @relation("TransactionToAccount") - outTransactions Transaction[] @relation("TransactionFromAccount") + inTransactions Transaction[] @relation("TransactionToAccount") + outTransactions Transaction[] @relation("TransactionFromAccount") // products Product[] } +enum TransactionStatus { + PENDING + SUCCEEDED + FAILED +} + enum TransactionType { DEPOSIT PAYMENT @@ -24,18 +30,50 @@ enum TransactionType { } model Transaction { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) amount Int // In øre - fromAccount LedgerAccount? @relation(fields: [fromAccountId], references: [id], name: "TransactionFromAccount", onDelete: Restrict, onUpdate: Cascade) + fee Int? // Also in øre + fromAccount LedgerAccount? @relation(fields: [fromAccountId], references: [id], name: "TransactionFromAccount", onDelete: Restrict, onUpdate: Cascade) fromAccountId Int? - toAccount LedgerAccount? @relation(fields: [toAccountId], references: [id], name: "TransactionToAccount", onDelete: Restrict, onUpdate: Cascade) + toAccount LedgerAccount? @relation(fields: [toAccountId], references: [id], name: "TransactionToAccount", onDelete: Restrict, onUpdate: Cascade) toAccountId Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - transactionType TransactionType - // deposit Deposit? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + status TransactionStatus + type TransactionType + deposit Deposit? // purchases Purchase[] // refunds Refund[] - // payout Payout? -} \ No newline at end of file + payout Payout? +} + +enum DepositType { + MANUAL + STRIPE + VIPPS // Not implemented yet +} + +model Deposit { + transaction Transaction @relation(fields: [transactionId], references: [id]) + transactionId Int @unique + type DepositType + stripeDeposit StripeDeposit? +} + +model StripeDeposit { + deposit Deposit @relation(fields: [transactionId], references: [transactionId]) + transactionId Int @unique + paymentIntentId String @unique + clientSecret String @unique +} + +// TODO: Implement Vipps deposit +// model VippsDeposit { +// ... +// } + +model Payout { + transaction Transaction @relation(fields: [transactionId], references: [id]) + transactionId Int @unique + accountNumber String // For display only +} diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index c88e4065b..6c1e02086 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -107,18 +107,28 @@ export namespace LedgerAccountMethods { const result = await prisma.transaction.aggregate({ _sum: { amount: true, + fee: true, + }, + where: { + ...where, + status: 'SUCCEEDED', }, - where, }) - return result._sum.amount ?? 0 + return { + total: result._sum.amount ?? 0, + fees: result._sum.fee ?? 0, + } } - const [totalIn, totalOut] = await Promise.all([ + const [sumIn, sumOut] = await Promise.all([ sumTransactions({ toAccountId: params.id }), sumTransactions({ fromAccountId: params.id }), ]) - return (totalIn - totalOut) / 100 + return { + total: sumIn.total - sumOut.total, + fees: sumIn.fees - sumOut.fees, + } } }) } \ No newline at end of file diff --git a/src/services/ledger/stripeEvent/methods.ts b/src/services/ledger/stripeEvent/methods.ts new file mode 100644 index 000000000..99164e363 --- /dev/null +++ b/src/services/ledger/stripeEvent/methods.ts @@ -0,0 +1,11 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; + +export namespace StripeEventMethods { + export const handleEvent = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + // TODO: Find out to how to validate stripe data + method: async ({}) => { + } + }) +} \ No newline at end of file diff --git a/src/services/ledger/stripeEvent/validation.ts b/src/services/ledger/stripeEvent/validation.ts new file mode 100644 index 000000000..444d18915 --- /dev/null +++ b/src/services/ledger/stripeEvent/validation.ts @@ -0,0 +1,2 @@ +import { Validation, ValidationBase } from "@/services/Validation"; +import { z } from "zod"; diff --git a/src/services/ledger/transactions/deposits/config.ts b/src/services/ledger/transactions/deposits/config.ts new file mode 100644 index 000000000..a002319cf --- /dev/null +++ b/src/services/ledger/transactions/deposits/config.ts @@ -0,0 +1 @@ +const minimumAmount = 5000; // 50 kr \ No newline at end of file diff --git a/src/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts index bbbee7a6d..cba6dedd4 100644 --- a/src/services/ledger/transactions/deposits/methods.ts +++ b/src/services/ledger/transactions/deposits/methods.ts @@ -2,20 +2,45 @@ import { RequireNothing } from "@/auth/auther/RequireNothing"; import { ServiceMethod } from "@/services/ServiceMethod"; import { z } from "zod"; import { createDepositValidation } from "./validation"; +import { stripe } from "@/lib/stripe"; +import { ServerError } from "@/services/error"; export namespace DepositMethods { - export const create = ServiceMethod({ + export const createStripe = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ accountId: z.number(), }), dataValidation: createDepositValidation, method: async ({ prisma, params, data }) => { - return prisma.transaction.create({ + const paymentIntent = await stripe.paymentIntents.create({ + amount: data.amount, + currency: 'nok', + description: 'Innskudd', + statement_descriptor: 'Omegaveven Innskudd', + }) + + if (paymentIntent.client_secret === null) { + throw new ServerError('UNKNOWN ERROR', 'Noe gikk galt med forespørselen til Stripe.') + } + + return prisma.stripeDeposit.create({ data: { - transactionType: 'DEPOSIT', - toAccountId: params.accountId, - amount: data.amount, + clientSecret: paymentIntent.client_secret, + paymentIntentId: paymentIntent.id, + deposit: { + create: { + type: 'STRIPE', + transaction: { + create: { + status: 'PENDING', + type: 'DEPOSIT', + toAccountId: params.accountId, + amount: data.amount, + } + } + } + } } }) }, diff --git a/src/services/ledger/transactions/deposits/validation.ts b/src/services/ledger/transactions/deposits/validation.ts index aeb5009ef..74ee16e5c 100644 --- a/src/services/ledger/transactions/deposits/validation.ts +++ b/src/services/ledger/transactions/deposits/validation.ts @@ -4,7 +4,7 @@ import type { ValidationTypes } from '@/services/Validation' const baseDepositValidation = new ValidationBase({ details: { - amount: z.coerce.number().int().positive(), + amount: z.coerce.number().int().positive().gte(minimumAmount, `Innskudd må være på minst ${minimumAmount}.`), }, type: { amount: z.coerce.number(), diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts index 93817afc6..a96d8da31 100644 --- a/src/services/ledger/transactions/methods.ts +++ b/src/services/ledger/transactions/methods.ts @@ -24,6 +24,10 @@ export namespace TransactionMethods { { toAccountId: params.paging.details.accountId }, ] }, + include: { + deposit: true, + payout: true, + }, orderBy: { createdAt: 'desc', }, diff --git a/src/services/ledger/transactions/payment/methods.ts b/src/services/ledger/transactions/payment/methods.ts index 83515c79b..3457d9339 100644 --- a/src/services/ledger/transactions/payment/methods.ts +++ b/src/services/ledger/transactions/payment/methods.ts @@ -21,7 +21,8 @@ export namespace PaymentMethods { return prisma.$transaction(async (tx) => { await tx.transaction.create({ data: { - transactionType: 'PAYMENT', + status: 'SUCCEEDED', + type: 'PAYMENT', fromAccountId: params.fromAccountId, toAccountId: data.toAccountId, amount: data.amount, @@ -35,9 +36,13 @@ export namespace PaymentMethods { session, }) - if (newBalancee < 0) { + if (newBalancee.total < 0) { throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') } + + if (newBalancee.fees < 0) { + throw new ServerError('BAD DATA', 'Kontoen skylder ikke nok avgifter for å utføre transaksjonsgebyret.') + } }) }, }) diff --git a/src/services/ledger/transactions/payouts/methods.ts b/src/services/ledger/transactions/payouts/methods.ts index 96b968524..f7d41a92f 100644 --- a/src/services/ledger/transactions/payouts/methods.ts +++ b/src/services/ledger/transactions/payouts/methods.ts @@ -2,7 +2,7 @@ import { RequireNothing } from "@/auth/auther/RequireNothing"; import { ServiceMethod } from "@/services/ServiceMethod"; import { z } from "zod"; import { createPayoutValidation } from "./validation"; -import { LedgerAccountMethods } from "../../ledgerAccount/methods"; +import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; import { ServerError } from "@/services/error"; export namespace PayoutMethods { @@ -15,9 +15,20 @@ export namespace PayoutMethods { opensTransaction: true, method: async ({ prisma, session, params, data }) => { return prisma.$transaction(async (tx) => { + const originalBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ + params: { + id: params.accountId, + }, + session, + }) + + const feesToYoink = Math.round((data.amount / originalBalancee.total) * originalBalancee.fees) + const payout = await tx.transaction.create({ data: { - transactionType: 'PAYOUT', + status: 'SUCCEEDED', + type: 'PAYOUT', + fee: feesToYoink, fromAccountId: params.accountId, amount: data.amount, } @@ -30,9 +41,15 @@ export namespace PayoutMethods { session, }) - if (newBalancee < 0) { + if (newBalancee.total < 0) { throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') } + + if (newBalancee.fees < 0) { + throw new ServerError('BAD DATA', 'Dette burde ikke være mulig...') + } + + return payout }) }, }) diff --git a/tests/services/ledger/deposits.test.ts b/tests/services/ledger/deposits.test.ts new file mode 100644 index 000000000..8a6075d8b --- /dev/null +++ b/tests/services/ledger/deposits.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from "@jest/globals"; + +describe('deposits', () => { + test('nothing', () => { + + }) +}) \ No newline at end of file diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts new file mode 100644 index 000000000..46132cfcb --- /dev/null +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from "@jest/globals"; + +describe('ledgerAccount', () => { + test('nothing', () => { + + }) +}) \ No newline at end of file diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts new file mode 100644 index 000000000..fd0fb5572 --- /dev/null +++ b/tests/services/ledger/payments.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from "@jest/globals"; + +describe('payments', () => { + test('nothing', () => { + + }) +}) \ No newline at end of file diff --git a/tests/services/ledger/payouts.test.ts b/tests/services/ledger/payouts.test.ts new file mode 100644 index 000000000..aa828060d --- /dev/null +++ b/tests/services/ledger/payouts.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from "@jest/globals"; + +describe('payouts', () => { + test('nothing', () => { + + }) +}) \ No newline at end of file diff --git a/tests/services/ledger/transactions.test.ts b/tests/services/ledger/transactions.test.ts new file mode 100644 index 000000000..7ed5da8f7 --- /dev/null +++ b/tests/services/ledger/transactions.test.ts @@ -0,0 +1,7 @@ +import { describe, test } from "@jest/globals"; + +describe('transactions', () => { + test('nothing', () => { + + }) +}) \ No newline at end of file From bd01d6a38870e2ba1cced4bf65b6025904ded4b7 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 27 Apr 2025 20:53:58 +0200 Subject: [PATCH 06/62] chore: update package lock --- package-lock.json | 729 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 583 insertions(+), 146 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7334a31d..222e8a451 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,9 +98,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", + "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", "dev": true, "license": "MIT", "engines": { @@ -108,22 +108,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", - "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", + "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", + "@babel/generator": "^7.26.5", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.9", - "@babel/types": "^7.26.9", + "@babel/helpers": "^7.26.7", + "@babel/parser": "^7.26.7", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.26.7", + "@babel/types": "^7.26.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -162,16 +162,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", + "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^7.26.5", + "@babel/types": "^7.26.5", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" }, "engines": { @@ -409,27 +409,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", + "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", + "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.26.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -746,43 +746,43 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", + "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@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" + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.7", + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.7", + "debug": "^4.3.1", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", + "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", "dev": true, "license": "MIT", "dependencies": { @@ -814,6 +814,8 @@ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -827,6 +829,8 @@ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -842,14 +846,429 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", - "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2390,23 +2809,23 @@ } }, "node_modules/@stripe/react-stripe-js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.3.0.tgz", - "integrity": "sha512-Qg4rUxhHNm8OPFuUPnzsU5eLYWhpKKMMs378f67BD7vG0RKttmeeaUDjObs83imRlSxv5L6WdDKiv3RXi/RfSw==", + "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 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@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.9.2", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.9.2.tgz", - "integrity": "sha512-g8qabMEu8zoXujyebWHkeQrM6k9Dm8h22FUaMNIFFTv4GtrWBhLYphhsra/PBEcM3p+mRr/srEIj9g9RV5d0xg==", + "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" @@ -2432,28 +2851,36 @@ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2995,6 +3422,8 @@ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "acorn": "^8.11.0" }, @@ -3116,7 +3545,9 @@ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/argparse": { "version": "2.0.1", @@ -3646,9 +4077,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001703", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz", - "integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==", + "version": "1.0.30001697", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001697.tgz", + "integrity": "sha512-GwNPlWJin8E+d7Gxq96jxM6w0w+VFeyyXRsjU58emtkYqnbwHqXm5uT2uCmO0RQE9htWknOP4xtBlLmM/gWxvQ==", "funding": [ { "type": "opencollective", @@ -3982,7 +4413,9 @@ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -4063,11 +4496,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4254,6 +4688,8 @@ "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.3.1" } @@ -4378,9 +4814,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.114", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz", - "integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==", + "version": "1.5.93", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.93.tgz", + "integrity": "sha512-M+29jTcfNNoR9NV7la4SwUqzWAxEwnc7ThA5e1m6LRSotmpfpCpLcIfgtSCVL+MllNLgAyM/5ru86iMRemPzDQ==", "dev": true, "license": "ISC" }, @@ -4624,10 +5060,10 @@ } }, "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==", - "devOptional": true, + "version": "0.25.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4637,31 +5073,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": { @@ -4837,29 +5273,6 @@ } } }, - "node_modules/eslint-import-resolver-typescript/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-typescript/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/eslint-module-utils": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", @@ -5419,6 +5832,21 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8065,7 +8493,9 @@ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/make-event-props": { "version": "1.6.2", @@ -8743,9 +9173,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/nan": { "version": "2.20.0", @@ -8754,9 +9185,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "funding": [ { "type": "github", @@ -10837,9 +11268,10 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -11338,6 +11770,8 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11522,11 +11956,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" @@ -11657,9 +12090,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", + "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "dev": true, "funding": [ { @@ -11718,7 +12151,9 @@ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -12152,6 +12587,8 @@ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } From 121e5d01988b4240285f7bb65c5d59139bad97f7 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 27 Apr 2025 22:16:07 +0200 Subject: [PATCH 07/62] chore: update ledger methods to use schemas --- src/actions/ledger/ledgerAccount.ts | 8 +- src/actions/ledger/transactions/deposits.ts | 8 +- src/actions/ledger/transactions/payments.ts | 8 +- src/actions/ledger/transactions/payouts.ts | 8 +- .../ledger/transactions/transactions.ts | 8 +- .../Ledger/LedgerAccountBalance.tsx | 10 +-- .../TransactionList/TransactionList.tsx | 12 +-- .../Ledger/TransactionList/TransactionRow.tsx | 6 +- src/app/_components/Stripe/DepositForm.tsx | 13 +-- src/app/_components/Stripe/PaymentForm.tsx | 13 +-- .../_components/Stripe/PaymentProvider.tsx | 10 +-- src/app/api/stripe-event/route.ts | 16 ++-- src/app/checkout/page.tsx | 4 +- src/app/users/[username]/(user-admin)/Nav.tsx | 2 +- .../[username]/(user-admin)/account/page.tsx | 42 ++++++---- .../account/transactions/page.tsx | 12 +-- src/contexts/paging/TranasctionPaging.tsx | 10 +-- src/lib/currency/config.ts | 2 +- src/services/ledger/ledgerAccount/methods.ts | 40 +++++----- src/services/ledger/ledgerAccount/schemas.ts | 32 ++++++++ .../ledger/ledgerAccount/validation.ts | 35 -------- src/services/ledger/stripeEvent/methods.ts | 6 +- src/services/ledger/stripeEvent/validation.ts | 2 - .../ledger/transactions/deposits/config.ts | 2 +- .../ledger/transactions/deposits/methods.ts | 16 ++-- .../ledger/transactions/deposits/schemas.ts | 8 ++ .../transactions/deposits/validation.ts | 19 ----- src/services/ledger/transactions/methods.ts | 46 +++++------ .../ledger/transactions/payment/methods.ts | 16 ++-- .../ledger/transactions/payment/schemas.ts | 8 ++ .../ledger/transactions/payment/validation.ts | 21 ----- .../ledger/transactions/payouts/methods.ts | 80 +++++++++---------- .../ledger/transactions/payouts/schemas.ts | 7 ++ .../ledger/transactions/payouts/validation.ts | 19 ----- .../ledger/transactions/validation.ts | 0 tests/services/ledger/deposits.test.ts | 6 +- tests/services/ledger/ledgerAccounts.test.ts | 6 +- tests/services/ledger/payments.test.ts | 6 +- tests/services/ledger/payouts.test.ts | 4 +- tests/services/ledger/transactions.test.ts | 6 +- 40 files changed, 272 insertions(+), 305 deletions(-) create mode 100644 src/services/ledger/ledgerAccount/schemas.ts delete mode 100644 src/services/ledger/ledgerAccount/validation.ts create mode 100644 src/services/ledger/transactions/deposits/schemas.ts delete mode 100644 src/services/ledger/transactions/deposits/validation.ts create mode 100644 src/services/ledger/transactions/payment/schemas.ts delete mode 100644 src/services/ledger/transactions/payment/validation.ts create mode 100644 src/services/ledger/transactions/payouts/schemas.ts delete mode 100644 src/services/ledger/transactions/payouts/validation.ts delete mode 100644 src/services/ledger/transactions/validation.ts diff --git a/src/actions/ledger/ledgerAccount.ts b/src/actions/ledger/ledgerAccount.ts index 74d02d52f..0ca70440c 100644 --- a/src/actions/ledger/ledgerAccount.ts +++ b/src/actions/ledger/ledgerAccount.ts @@ -1,8 +1,8 @@ -"use server" +'use server' -import { action } from "@/actions/action"; -import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; +import { action } from '@/actions/action' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' export const createLedgerAccount = action(LedgerAccountMethods.create) export const readLedgerAccount = action(LedgerAccountMethods.read) -export const calculateLedgerAccountBalance = action(LedgerAccountMethods.calculateBalance) \ No newline at end of file +export const calculateLedgerAccountBalance = action(LedgerAccountMethods.calculateBalance) diff --git a/src/actions/ledger/transactions/deposits.ts b/src/actions/ledger/transactions/deposits.ts index e6318a58c..a883ec793 100644 --- a/src/actions/ledger/transactions/deposits.ts +++ b/src/actions/ledger/transactions/deposits.ts @@ -1,6 +1,6 @@ -"use server" +'use server' -import { action } from "@/actions/action"; -import { DepositMethods } from "@/services/ledger/transactions/deposits/methods"; +import { action } from '@/actions/action' +import { DepositMethods } from '@/services/ledger/transactions/deposits/methods' -export const createStripeDeposit = action(DepositMethods.createStripe) \ No newline at end of file +export const createStripeDeposit = action(DepositMethods.createStripe) diff --git a/src/actions/ledger/transactions/payments.ts b/src/actions/ledger/transactions/payments.ts index 50605b789..6f6ab5627 100644 --- a/src/actions/ledger/transactions/payments.ts +++ b/src/actions/ledger/transactions/payments.ts @@ -1,6 +1,6 @@ -"use server" +'use server' -import { action } from "@/actions/action" -import { PaymentMethods } from "@/services/ledger/transactions/payment/methods" +import { action } from '@/actions/action' +import { PaymentMethods } from '@/services/ledger/transactions/payment/methods' -export const createPayment = action(PaymentMethods.create) \ No newline at end of file +export const createPayment = action(PaymentMethods.create) diff --git a/src/actions/ledger/transactions/payouts.ts b/src/actions/ledger/transactions/payouts.ts index c18f6ff17..4807ab78a 100644 --- a/src/actions/ledger/transactions/payouts.ts +++ b/src/actions/ledger/transactions/payouts.ts @@ -1,6 +1,6 @@ -"use server" +'use server' -import { action } from "@/actions/action"; -import { PayoutMethods } from "@/services/ledger/transactions/payouts/methods"; +import { action } from '@/actions/action' +import { PayoutMethods } from '@/services/ledger/transactions/payouts/methods' -export const createPayout = action(PayoutMethods.create) \ No newline at end of file +export const createPayout = action(PayoutMethods.create) diff --git a/src/actions/ledger/transactions/transactions.ts b/src/actions/ledger/transactions/transactions.ts index 2795f7108..46f6dca7f 100644 --- a/src/actions/ledger/transactions/transactions.ts +++ b/src/actions/ledger/transactions/transactions.ts @@ -1,6 +1,6 @@ -"use server" +'use server' -import { action } from "@/actions/action" -import { TransactionMethods } from "@/services/ledger/transactions/methods" +import { action } from '@/actions/action' +import { TransactionMethods } from '@/services/ledger/transactions/methods' -export const readTransactionsPage = action(TransactionMethods.readPage) \ No newline at end of file +export const readTransactionsPage = action(TransactionMethods.readPage) diff --git a/src/app/_components/Ledger/LedgerAccountBalance.tsx b/src/app/_components/Ledger/LedgerAccountBalance.tsx index 25d328a2c..171c233c5 100644 --- a/src/app/_components/Ledger/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/LedgerAccountBalance.tsx @@ -1,6 +1,6 @@ -import { calculateLedgerAccountBalance } from "@/actions/ledger/ledgerAccount" -import { unwrapActionReturn } from "@/app/redirectToErrorPage" -import { displayAmount } from "@/lib/currency/convert" +import { calculateLedgerAccountBalance } from '@/actions/ledger/ledgerAccount' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { displayAmount } from '@/lib/currency/convert' type Props = { accountId: number, @@ -9,9 +9,9 @@ type Props = { export default async function LedgerAccountBalance({ accountId, showFees }: Props) { const balance = unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) - + return

Balanse: {displayAmount(balance.total)} Kluengende muent

{showFees &&

Avgifter: {displayAmount(balance.fees)} Kluengende muent

}
-} \ No newline at end of file +} diff --git a/src/app/_components/Ledger/TransactionList/TransactionList.tsx b/src/app/_components/Ledger/TransactionList/TransactionList.tsx index 08ea16d2d..5124dab07 100644 --- a/src/app/_components/Ledger/TransactionList/TransactionList.tsx +++ b/src/app/_components/Ledger/TransactionList/TransactionList.tsx @@ -1,15 +1,15 @@ -"use client" +'use client' -import TransactionPagingProvider, { TransactionPagingContext } from "@/contexts/paging/TranasctionPaging"; +import TransactionRow from './TransactionRow' +import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import TransactionRow from "./TransactionRow"; type Props = { accountId: number, - showFees?: boolean, + // TODO: showFees?: boolean, } -export default function TransactionList({ accountId, showFees }: Props) { +export default function TransactionList({ accountId }: Props) { return -} \ No newline at end of file +} diff --git a/src/app/_components/Ledger/TransactionList/TransactionRow.tsx b/src/app/_components/Ledger/TransactionList/TransactionRow.tsx index 77195035e..12414bb69 100644 --- a/src/app/_components/Ledger/TransactionList/TransactionRow.tsx +++ b/src/app/_components/Ledger/TransactionList/TransactionRow.tsx @@ -1,6 +1,6 @@ -import { displayAmount } from "@/lib/currency/convert" -import { Transaction } from "@prisma/client" import styles from './TransactionRow.module.scss' +import { displayAmount } from '@/lib/currency/convert' +import type { Transaction } from '@prisma/client' type Props = { transaction: Transaction, @@ -15,4 +15,4 @@ export default function TransactionRow({ transaction, showFees }: Props) {

{transaction.type}

{transaction.status}

-} \ No newline at end of file +} diff --git a/src/app/_components/Stripe/DepositForm.tsx b/src/app/_components/Stripe/DepositForm.tsx index 9d4c070ee..ae43bb423 100644 --- a/src/app/_components/Stripe/DepositForm.tsx +++ b/src/app/_components/Stripe/DepositForm.tsx @@ -1,9 +1,10 @@ -"use client" +'use client' -import { ChangeEvent, useState } from "react" -import PaymentProvider from "./PaymentProvider" -import PaymentForm from "./PaymentForm" -import NumberInput from "../UI/NumberInput" +import PaymentProvider from './PaymentProvider' +import PaymentForm from './PaymentForm' +import NumberInput from '@/components/UI/NumberInput' +import { useState } from 'react' +import type { ChangeEvent } from 'react' type Props = { accountId: number, @@ -24,4 +25,4 @@ export default function DepositForm({ accountId }: Props) {
-} \ No newline at end of file +} diff --git a/src/app/_components/Stripe/PaymentForm.tsx b/src/app/_components/Stripe/PaymentForm.tsx index 015a68a4b..40a411e28 100644 --- a/src/app/_components/Stripe/PaymentForm.tsx +++ b/src/app/_components/Stripe/PaymentForm.tsx @@ -1,9 +1,10 @@ -"use client" +'use client' -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import Form from '@/components/Form/Form'; -import { createStripeDeposit } from '@/actions/ledger/transactions/deposits'; -import { createActionError } from '@/actions/error'; +import Form from '@/components/Form/Form' +import { createStripeDeposit } from '@/actions/ledger/transactions/deposits' +import { createActionError } from '@/actions/error' +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import React from 'react' type Props = { accountId: number, @@ -52,4 +53,4 @@ export default function PaymentForm({ accountId, children }: Props) { {children} -} \ No newline at end of file +} diff --git a/src/app/_components/Stripe/PaymentProvider.tsx b/src/app/_components/Stripe/PaymentProvider.tsx index a8c521378..a4f4156db 100644 --- a/src/app/_components/Stripe/PaymentProvider.tsx +++ b/src/app/_components/Stripe/PaymentProvider.tsx @@ -1,8 +1,8 @@ -"use client" +'use client' -import { Elements } from "@stripe/react-stripe-js" -import { loadStripe } from "@stripe/stripe-js" -import type { ReactNode } from "react" +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') @@ -25,4 +25,4 @@ export default function PaymentProvider({ children, amount }: Props) { {children} } -} \ No newline at end of file +} diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts index 4303706f8..050e274d6 100644 --- a/src/app/api/stripe-event/route.ts +++ b/src/app/api/stripe-event/route.ts @@ -1,21 +1,22 @@ -import logger from "@/lib/logger"; -import { stripe } from "@/lib/stripe" +import logger from '@/lib/logger' +import { stripe } from '@/lib/stripe' +import prisma from '@/prisma' 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(); - + 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) - - if (event.type != 'charge.succeeded' && event.type != 'charge.updated') { + + if (event.type !== 'charge.succeeded' && event.type !== 'charge.updated') { logger.warn(`Unhandled Stripe event received: ${event.type}`) return new Response('', { status: 200 }) } @@ -44,4 +45,3 @@ export async function POST(req: Request) { return new Response('', { status: 200 }) } - \ No newline at end of file diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index 9f2b05699..5fa678bb4 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -1,4 +1,4 @@ -import Button from "../_components/UI/Button"; +import Button from '@/components/UI/Button' export default async function Checkout() { return
@@ -6,4 +6,4 @@ export default async function Checkout() {

Her kommer kassen din!

-} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx index ef41e75e6..5875e4476 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, faCoins, faKey, faMoneyBill, faPaperPlane } from '@fortawesome/free-solid-svg-icons' +import { faCircleDot, faCog, faKey, faMoneyBill, faPaperPlane } from '@fortawesome/free-solid-svg-icons' import { usePathname } from 'next/navigation' type PropTypes = { diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 4ff26bb4e..c171e943b 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,16 +1,16 @@ -import { bindParams } from "@/actions/bind" -import { calculateLedgerAccountBalance, readLedgerAccount } from "@/actions/ledger/ledgerAccount" -import { createPayment } from "@/actions/ledger/transactions/payments" -import { createPayout } from "@/actions/ledger/transactions/payouts" -import Form from "@/app/_components/Form/Form" -import LedgerAccountBalance from "@/app/_components/Ledger/LedgerAccountBalance" -import DepositForm from "@/app/_components/Stripe/DepositForm" -import NumberInput from "@/app/_components/UI/NumberInput" -import TextInput from "@/app/_components/UI/TextInput" -import { unwrapActionReturn } from "@/app/redirectToErrorPage" -import { getUser } from "@/auth/getUser" -import Button from "@/components/UI/Button" -import Link from "next/link" +import { bindParams } from '@/actions/bind' +import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' +import { createPayment } from '@/actions/ledger/transactions/payments' +import { createPayout } from '@/actions/ledger/transactions/payouts' +import Form from '@/app/_components/Form/Form' +import LedgerAccountBalance from '@/app/_components/Ledger/LedgerAccountBalance' +import DepositForm from '@/app/_components/Stripe/DepositForm' +import NumberInput from '@/app/_components/UI/NumberInput' +import TextInput from '@/app/_components/UI/TextInput' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { getUser } from '@/auth/getUser' +import Button from '@/components/UI/Button' +import Link from 'next/link' export default async function Account() { const session = await getUser({ @@ -19,7 +19,7 @@ export default async function Account() { }) // TODO: Replace const account = unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) - + return

Konto

@@ -28,13 +28,21 @@ export default async function Account() {

Betaling

-
+

Utbetaling

-
+
@@ -46,4 +54,4 @@ export default async function Account() {

VIPPS (TODO)

-} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index c4585e79b..92032e7fe 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,7 +1,7 @@ -import { readLedgerAccount } from "@/actions/ledger/ledgerAccount" -import TransactionList from "@/app/_components/Ledger/TransactionList/TransactionList"; -import { unwrapActionReturn } from "@/app/redirectToErrorPage" -import { getUser } from "@/auth/getUser" +import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' +import TransactionList from '@/app/_components/Ledger/TransactionList/TransactionList' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { getUser } from '@/auth/getUser' export default async function Transactions() { // const transactionPagingContext = useContext(TransactionPagingContext) @@ -15,7 +15,7 @@ export default async function Transactions() { shouldRedirect: true, }) - const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })); + const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })) return -} \ No newline at end of file +} diff --git a/src/contexts/paging/TranasctionPaging.tsx b/src/contexts/paging/TranasctionPaging.tsx index fa2d68619..4ab2cff77 100644 --- a/src/contexts/paging/TranasctionPaging.tsx +++ b/src/contexts/paging/TranasctionPaging.tsx @@ -1,14 +1,14 @@ 'use client' -import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' import type { ReadPageInput } from '@/lib/paging/Types' -import { Transaction } from '@prisma/client' +import type { Transaction } from '@prisma/client' export type PageSizeTransactions = 10 -const fetcher = async (paging: ReadPageInput) => { - return readTransactionsPage({ paging }) -} +const fetcher = async ( + paging: ReadPageInput +) => readTransactionsPage({ paging }) export const TransactionPagingContext = generatePagingContext< Transaction, diff --git a/src/lib/currency/config.ts b/src/lib/currency/config.ts index bf796c17e..4289ebbee 100644 --- a/src/lib/currency/config.ts +++ b/src/lib/currency/config.ts @@ -1 +1 @@ -export const currencySymbol = 'Klinguende Meunt' \ No newline at end of file +export const currencySymbol = 'Klinguende Meunt' diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 6c1e02086..7a2439268 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -1,24 +1,24 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { createAccountValidation } from "./validation"; -import { ServerError } from "@/services/error"; -import { z } from "zod"; -import { Prisma } from "@prisma/client"; +import { LedgerAccountSchemas } from './schemas' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServiceMethod } from '@/services/ServiceMethod' +import { ServerError } from '@/services/error' +import { z } from 'zod' +import type { Prisma } from '@prisma/client' 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({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - dataValidation: createAccountValidation, + dataSchema: LedgerAccountSchemas.create, method: async ({ prisma, data }) => { const type = data.userId === undefined ? 'GROUP' : 'USER' @@ -42,13 +42,15 @@ export namespace LedgerAccountMethods { }) /** - * 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. - * + * 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({ @@ -81,9 +83,9 @@ export namespace LedgerAccountMethods { /** * Calculates the balance of an account. - * + * * @param params.id The ID of the account to calculate the balance for. - * + * * @returns The balance of the account. */ export const calculateBalance = ServiceMethod({ @@ -119,7 +121,7 @@ export namespace LedgerAccountMethods { fees: result._sum.fee ?? 0, } } - + const [sumIn, sumOut] = await Promise.all([ sumTransactions({ toAccountId: params.id }), sumTransactions({ fromAccountId: params.id }), @@ -131,4 +133,4 @@ export namespace LedgerAccountMethods { } } }) -} \ No newline at end of file +} diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/ledgerAccount/schemas.ts new file mode 100644 index 000000000..717ebf4c0 --- /dev/null +++ b/src/services/ledger/ledgerAccount/schemas.ts @@ -0,0 +1,32 @@ +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.' + ) + + // .createValidation({ + // keys: ['userId', 'groupId', 'payoutAccountNumber'], + // transformer: data => data, + // refiner: { + // // Only one of userId and groupId can be set + // fcn: data => (data.userId === undefined) !== (data.groupId === undefined), + // message: 'Bruker- eller gruppe-ID må være satt.', + // }, + // }) + + export const update = fields.partial().pick({ + payoutAccountNumber: true, + }) +} diff --git a/src/services/ledger/ledgerAccount/validation.ts b/src/services/ledger/ledgerAccount/validation.ts deleted file mode 100644 index 28258b57a..000000000 --- a/src/services/ledger/ledgerAccount/validation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ValidationBase } from '@/services/Validation' -import { z } from 'zod' -import type { ValidationTypes } from '@/services/Validation' - -export const baseAccountValidation = new ValidationBase({ - type: { - userId: z.number().optional(), - groupId: z.number().optional(), - payoutAccountNumber: z.string().optional(), - }, - details: { - userId: z.number().optional(), - groupId: z.number().optional(), - payoutAccountNumber: z.string().optional(), - }, -}) - -export const createAccountValidation = baseAccountValidation.createValidation({ - keys: ['userId', 'groupId', 'payoutAccountNumber'], - transformer: data => data, - refiner: { - // Only one of userId and groupId can be set - fcn: data => (data.userId === undefined) !== (data.groupId === undefined), - message: 'Bruker- eller gruppe-ID må være satt.', - }, -}) - -export type CreateAccountTypes = ValidationTypes - -export const updateAccountValidation = baseAccountValidation.createValidation({ - keys: ['payoutAccountNumber'], - transformer: data => data, -}) - -export type UpdateAccountTypes = ValidationTypes diff --git a/src/services/ledger/stripeEvent/methods.ts b/src/services/ledger/stripeEvent/methods.ts index 99164e363..257b5322f 100644 --- a/src/services/ledger/stripeEvent/methods.ts +++ b/src/services/ledger/stripeEvent/methods.ts @@ -1,5 +1,5 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServiceMethod } from '@/services/ServiceMethod' export namespace StripeEventMethods { export const handleEvent = ServiceMethod({ @@ -8,4 +8,4 @@ export namespace StripeEventMethods { method: async ({}) => { } }) -} \ No newline at end of file +} diff --git a/src/services/ledger/stripeEvent/validation.ts b/src/services/ledger/stripeEvent/validation.ts index 444d18915..e69de29bb 100644 --- a/src/services/ledger/stripeEvent/validation.ts +++ b/src/services/ledger/stripeEvent/validation.ts @@ -1,2 +0,0 @@ -import { Validation, ValidationBase } from "@/services/Validation"; -import { z } from "zod"; diff --git a/src/services/ledger/transactions/deposits/config.ts b/src/services/ledger/transactions/deposits/config.ts index a002319cf..366b7a5a7 100644 --- a/src/services/ledger/transactions/deposits/config.ts +++ b/src/services/ledger/transactions/deposits/config.ts @@ -1 +1 @@ -const minimumAmount = 5000; // 50 kr \ No newline at end of file +export const minimumAmount = 5000 // 50 kr diff --git a/src/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts index cba6dedd4..993162490 100644 --- a/src/services/ledger/transactions/deposits/methods.ts +++ b/src/services/ledger/transactions/deposits/methods.ts @@ -1,9 +1,9 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { z } from "zod"; -import { createDepositValidation } from "./validation"; -import { stripe } from "@/lib/stripe"; -import { ServerError } from "@/services/error"; +import { DepositSchemas } from './schemas' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServiceMethod } from '@/services/ServiceMethod' +import { stripe } from '@/lib/stripe' +import { ServerError } from '@/services/error' +import { z } from 'zod' export namespace DepositMethods { export const createStripe = ServiceMethod({ @@ -11,7 +11,7 @@ export namespace DepositMethods { paramsSchema: z.object({ accountId: z.number(), }), - dataValidation: createDepositValidation, + dataSchema: DepositSchemas.create, method: async ({ prisma, params, data }) => { const paymentIntent = await stripe.paymentIntents.create({ amount: data.amount, @@ -45,4 +45,4 @@ export namespace DepositMethods { }) }, }) -} \ No newline at end of file +} diff --git a/src/services/ledger/transactions/deposits/schemas.ts b/src/services/ledger/transactions/deposits/schemas.ts new file mode 100644 index 000000000..ba215089f --- /dev/null +++ b/src/services/ledger/transactions/deposits/schemas.ts @@ -0,0 +1,8 @@ +import { minimumAmount } from './config' +import { z } from 'zod' + +export namespace DepositSchemas { + export const create = z.object({ + amount: z.coerce.number().int().positive().gte(minimumAmount, `Innskudd må være på minst ${minimumAmount}.`), + }) +} diff --git a/src/services/ledger/transactions/deposits/validation.ts b/src/services/ledger/transactions/deposits/validation.ts deleted file mode 100644 index 74ee16e5c..000000000 --- a/src/services/ledger/transactions/deposits/validation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ValidationBase } from '@/services/Validation' -import { z } from 'zod' -import type { ValidationTypes } from '@/services/Validation' - -const baseDepositValidation = new ValidationBase({ - details: { - amount: z.coerce.number().int().positive().gte(minimumAmount, `Innskudd må være på minst ${minimumAmount}.`), - }, - type: { - amount: z.coerce.number(), - } -}) - -export const createDepositValidation = baseDepositValidation.createValidation({ - keys: ['amount'], - transformer: data => data, -}) - -export type CreateDepositTypes = ValidationTypes diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts index a96d8da31..bfd5387b2 100644 --- a/src/services/ledger/transactions/methods.ts +++ b/src/services/ledger/transactions/methods.ts @@ -1,8 +1,8 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { cursorPageingSelection } from "@/lib/paging/cursorPageingSelection"; -import { readPageInputSchemaObject } from "@/lib/paging/schema"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { z } from "zod"; +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { ServiceMethod } from '@/services/ServiceMethod' +import { z } from 'zod' export namespace TransactionMethods { export const readPage = ServiceMethod({ @@ -16,23 +16,21 @@ export namespace TransactionMethods { accountId: z.number(), }), ), - method: async ({ prisma, params }) => { - return prisma.transaction.findMany({ - where: { - OR: [ - { fromAccountId: params.paging.details.accountId }, - { toAccountId: params.paging.details.accountId }, - ] - }, - include: { - deposit: true, - payout: true, - }, - orderBy: { - createdAt: 'desc', - }, - ...cursorPageingSelection(params.paging.page) - }) - } + method: async ({ prisma, params }) => prisma.transaction.findMany({ + where: { + OR: [ + { fromAccountId: params.paging.details.accountId }, + { toAccountId: params.paging.details.accountId }, + ] + }, + include: { + deposit: true, + payout: true, + }, + orderBy: { + createdAt: 'desc', + }, + ...cursorPageingSelection(params.paging.page) + }) }) -} \ No newline at end of file +} diff --git a/src/services/ledger/transactions/payment/methods.ts b/src/services/ledger/transactions/payment/methods.ts index 3457d9339..df69c6db6 100644 --- a/src/services/ledger/transactions/payment/methods.ts +++ b/src/services/ledger/transactions/payment/methods.ts @@ -1,9 +1,9 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { z } from "zod"; -import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; -import { ServerError } from "@/services/error"; -import { createPaymentValidation } from "./validation"; +import { PaymentSchemas } from './schemas' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServiceMethod } from '@/services/ServiceMethod' +import { ServerError } from '@/services/error' +import { z } from 'zod' export namespace PaymentMethods { export const create = ServiceMethod({ @@ -11,7 +11,7 @@ export namespace PaymentMethods { paramsSchema: z.object({ fromAccountId: z.number(), }), - dataValidation: createPaymentValidation, + dataSchema: PaymentSchemas.create, opensTransaction: true, method: async ({ prisma, session, params, data }) => { if (params.fromAccountId === data.toAccountId) { @@ -46,4 +46,4 @@ export namespace PaymentMethods { }) }, }) -} \ No newline at end of file +} diff --git a/src/services/ledger/transactions/payment/schemas.ts b/src/services/ledger/transactions/payment/schemas.ts new file mode 100644 index 000000000..6d9a645de --- /dev/null +++ b/src/services/ledger/transactions/payment/schemas.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export namespace PaymentSchemas { + export const create = z.object({ + amount: z.coerce.number().int().positive(), + toAccountId: z.coerce.number().int(), + }) +} diff --git a/src/services/ledger/transactions/payment/validation.ts b/src/services/ledger/transactions/payment/validation.ts deleted file mode 100644 index dad136665..000000000 --- a/src/services/ledger/transactions/payment/validation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ValidationBase } from '@/services/Validation' -import { z } from 'zod' -import type { ValidationTypes } from '@/services/Validation' - -const basePaymentValidation = new ValidationBase({ - details: { - amount: z.coerce.number().int().positive(), - toAccountId: z.coerce.number().int(), - }, - type: { - amount: z.coerce.number(), - toAccountId: z.coerce.number(), - } -}) - -export const createPaymentValidation = basePaymentValidation.createValidation({ - keys: ['amount', 'toAccountId'], - transformer: data => data, -}) - -export type CreatePaymentTypes = ValidationTypes diff --git a/src/services/ledger/transactions/payouts/methods.ts b/src/services/ledger/transactions/payouts/methods.ts index f7d41a92f..d180c6c86 100644 --- a/src/services/ledger/transactions/payouts/methods.ts +++ b/src/services/ledger/transactions/payouts/methods.ts @@ -1,9 +1,9 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { z } from "zod"; -import { createPayoutValidation } from "./validation"; -import { LedgerAccountMethods } from "@/services/ledger/ledgerAccount/methods"; -import { ServerError } from "@/services/error"; +import { PayoutSchemas } from './schemas' +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { ServiceMethod } from '@/services/ServiceMethod' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { ServerError } from '@/services/error' +import { z } from 'zod' export namespace PayoutMethods { export const create = ServiceMethod({ @@ -11,46 +11,44 @@ export namespace PayoutMethods { paramsSchema: z.object({ accountId: z.number(), }), - dataValidation: createPayoutValidation, + dataSchema: PayoutSchemas.create, opensTransaction: true, - method: async ({ prisma, session, params, data }) => { - return prisma.$transaction(async (tx) => { - const originalBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ - params: { - id: params.accountId, - }, - session, - }) + method: async ({ prisma, session, params, data }) => prisma.$transaction(async (tx) => { + const originalBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ + params: { + id: params.accountId, + }, + session, + }) - const feesToYoink = Math.round((data.amount / originalBalancee.total) * originalBalancee.fees) + const feesToYoink = Math.round((data.amount / originalBalancee.total) * originalBalancee.fees) - const payout = await tx.transaction.create({ - data: { - status: 'SUCCEEDED', - type: 'PAYOUT', - fee: feesToYoink, - fromAccountId: params.accountId, - amount: data.amount, - } - }) + const payout = await tx.transaction.create({ + data: { + status: 'SUCCEEDED', + type: 'PAYOUT', + fee: feesToYoink, + fromAccountId: params.accountId, + amount: data.amount, + } + }) - const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ - params: { - id: params.accountId, - }, - session, - }) + const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ + params: { + id: params.accountId, + }, + session, + }) - if (newBalancee.total < 0) { - throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') - } + if (newBalancee.total < 0) { + throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') + } - if (newBalancee.fees < 0) { - throw new ServerError('BAD DATA', 'Dette burde ikke være mulig...') - } + if (newBalancee.fees < 0) { + throw new ServerError('BAD DATA', 'Dette burde ikke være mulig...') + } - return payout - }) - }, + return payout + }), }) -} \ No newline at end of file +} diff --git a/src/services/ledger/transactions/payouts/schemas.ts b/src/services/ledger/transactions/payouts/schemas.ts new file mode 100644 index 000000000..4e0fa3b29 --- /dev/null +++ b/src/services/ledger/transactions/payouts/schemas.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export namespace PayoutSchemas { + export const create = z.object({ + amount: z.coerce.number().int().positive(), + }) +} diff --git a/src/services/ledger/transactions/payouts/validation.ts b/src/services/ledger/transactions/payouts/validation.ts deleted file mode 100644 index ea70b3a3c..000000000 --- a/src/services/ledger/transactions/payouts/validation.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ValidationBase } from '@/services/Validation' -import { z } from 'zod' -import type { ValidationTypes } from '@/services/Validation' - -const basePayoutValidation = new ValidationBase({ - details: { - amount: z.coerce.number().int().positive(), - }, - type: { - amount: z.coerce.number(), - } -}) - -export const createPayoutValidation = basePayoutValidation.createValidation({ - keys: ['amount'], - transformer: data => data, -}) - -export type CreatePayoutTypes = ValidationTypes diff --git a/src/services/ledger/transactions/validation.ts b/src/services/ledger/transactions/validation.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/services/ledger/deposits.test.ts b/tests/services/ledger/deposits.test.ts index 8a6075d8b..555132bf7 100644 --- a/tests/services/ledger/deposits.test.ts +++ b/tests/services/ledger/deposits.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "@jest/globals"; +import { describe, test } from '@jest/globals' describe('deposits', () => { test('nothing', () => { - + }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts index 46132cfcb..10fe70756 100644 --- a/tests/services/ledger/ledgerAccounts.test.ts +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "@jest/globals"; +import { describe, test } from '@jest/globals' describe('ledgerAccount', () => { test('nothing', () => { - + }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts index fd0fb5572..1f0eada17 100644 --- a/tests/services/ledger/payments.test.ts +++ b/tests/services/ledger/payments.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "@jest/globals"; +import { describe, test } from '@jest/globals' describe('payments', () => { test('nothing', () => { - + }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/payouts.test.ts b/tests/services/ledger/payouts.test.ts index aa828060d..ce1e5cd08 100644 --- a/tests/services/ledger/payouts.test.ts +++ b/tests/services/ledger/payouts.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "@jest/globals"; +import { describe, test } from '@jest/globals' describe('payouts', () => { test('nothing', () => { }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/transactions.test.ts b/tests/services/ledger/transactions.test.ts index 7ed5da8f7..66e43a917 100644 --- a/tests/services/ledger/transactions.test.ts +++ b/tests/services/ledger/transactions.test.ts @@ -1,7 +1,7 @@ -import { describe, test } from "@jest/globals"; +import { describe, test } from '@jest/globals' describe('transactions', () => { test('nothing', () => { - + }) -}) \ No newline at end of file +}) From 32e5246e2262f24ea4b5bf3cc76bda3186ddcee5 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 1 May 2025 00:03:02 +0200 Subject: [PATCH 08/62] refactor: stripe event handling --- src/app/api/stripe-event/route.ts | 34 ++++++++---------- .../ledger/transactions/deposits/methods.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts index 050e274d6..d37ba2393 100644 --- a/src/app/api/stripe-event/route.ts +++ b/src/app/api/stripe-event/route.ts @@ -1,6 +1,6 @@ import logger from '@/lib/logger' import { stripe } from '@/lib/stripe' -import prisma from '@/prisma' +import { DepositMethods } from '@/services/ledger/transactions/deposits/methods' export async function POST(req: Request) { if (!process.env.STRIPE_WEBHOOK_SECRET) { @@ -15,33 +15,29 @@ export async function POST(req: Request) { } 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 }) } - const balanceTransaction = await stripe.balanceTransactions.retrieve(event.data.object.balance_transaction) - - const { transactionId } = await prisma.stripeDeposit.findUniqueOrThrow({ - where: { - paymentIntentId: event.data.object.payment_intent, - } - }) - - await prisma.transaction.update({ - where: { - id: transactionId, - }, - data: { - status: 'SUCCEEDED', - fee: balanceTransaction.fee, - } - }) + try { + await DepositMethods.confirmStripe.newClient().execute({ + session: null, + 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/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts index 993162490..c8bb449a9 100644 --- a/src/services/ledger/transactions/deposits/methods.ts +++ b/src/services/ledger/transactions/deposits/methods.ts @@ -6,6 +6,9 @@ import { ServerError } from '@/services/error' import { z } from 'zod' export namespace DepositMethods { + /** + * Creates a new stripe deposit and a new Stripe payment intent with the specified amount. + */ export const createStripe = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ @@ -45,4 +48,36 @@ export namespace DepositMethods { }) }, }) + + /** + * Confirms a Stripe deposit by updating the transaction status to SUCCEEDED. + * This method also retrieves and sets the fee from the Stripe balance transaction. + */ + export const confirmStripe = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + balanceTransactionId: z.string(), + paymentIntentId: z.string(), + }), + method: async ({ prisma, params }) => { + const { transactionId } = await prisma.stripeDeposit.findUniqueOrThrow({ + where: { + paymentIntentId: params.paymentIntentId, + } + }) + + const balanceTransaction = await stripe.balanceTransactions.retrieve(params.balanceTransactionId) + + await prisma.transaction.update({ + where: { + id: transactionId, + status: 'PENDING', + }, + data: { + status: 'SUCCEEDED', + fee: balanceTransaction.fee, + } + }) + }, + }) } From 4b4bb40469e3afefdc1dd5570d3546e1285bf566 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 10 Aug 2025 11:12:54 +0200 Subject: [PATCH 09/62] feat: ledger schema --- src/prisma/schema/ledger.prisma | 199 ++++++++++++++++++++++++-------- 1 file changed, 151 insertions(+), 48 deletions(-) diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 37c0b3a63..3b69983e9 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -1,8 +1,18 @@ +// 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) @@ -10,70 +20,163 @@ model LedgerAccount { group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull, onUpdate: Cascade) groupId Int? @unique type LedgerAccountType - payoutAccountNumber String? // For display only - inTransactions Transaction[] @relation("TransactionToAccount") - outTransactions Transaction[] @relation("TransactionFromAccount") + payoutAccountNumber String? // For display only, only used for group accounts + + ledgerEntries LedgerEntry[] + payments Payment[] + // products Product[] + + 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 } -enum TransactionStatus { +// All ledger transactions start as pending and become +// either failed, succeeded or canceled. No other +// transitions are possible. +enum LedgerTransactionStatus { PENDING - SUCCEEDED FAILED + SUCCEEDED + CANCELED } -enum TransactionType { - DEPOSIT - PAYMENT - REFUND - PAYOUT +// The system uses double-entry accounting (google it or read the wiki to know what this means) +// Each transaction consists of one or more LedgerEntries that must sum to zero +// Either all ledger entires in a transaction are valid or none are +model LedgerTransaction { + id Int @id @default(autoincrement()) + purpose LedgerTransactionPurpose + status LedgerTransactionStatus + ledgerEntries LedgerEntry[] + + // Relevant relations to other tables based un purpose + // purchase Purchase + // deposit Deposit + // payout Payout + // refund + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } -model Transaction { - id Int @id @default(autoincrement()) - amount Int // In øre - fee Int? // Also in øre - fromAccount LedgerAccount? @relation(fields: [fromAccountId], references: [id], name: "TransactionFromAccount", onDelete: Restrict, onUpdate: Cascade) - fromAccountId Int? - toAccount LedgerAccount? @relation(fields: [toAccountId], references: [id], name: "TransactionToAccount", onDelete: Restrict, onUpdate: Cascade) - toAccountId Int? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - status TransactionStatus - type TransactionType - deposit Deposit? - // purchases Purchase[] - // refunds Refund[] - payout Payout? +// All ledger entries part of a transaction must sum to zero +model LedgerEntry { + id Int @id @default(autoincrement()) + // The amount this ledger entry moves + // Credit when > 0, debit when < 0 + amount 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. Set to 0 explicitly for no fees. + fees Int? + // What type of movement this ledger entry does. + // Determines how it is validated. + // type LedgerEntryType + // The account which should be credited/debited on completions + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int? + // The payment the amount comes from in case this is an external debit + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId 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]) + // Same applies to payment id and transaction id + @@unique([ledgerTransactionId, paymentId]) } -enum DepositType { - MANUAL +enum PaymentProvider { + MANUAL // Admin injected money into the ledger STRIPE - VIPPS // Not implemented yet + // TODO: VIPPS } -model Deposit { - transaction Transaction @relation(fields: [transactionId], references: [id]) - transactionId Int @unique - type DepositType - stripeDeposit StripeDeposit? +enum PaymentStatus { + // 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 amount + SUCCEEDED + // Cancel webhook confirmation received - NOT that we initiated a cancel + // set the transaction status to canceled for that + CANCELED } -model StripeDeposit { - deposit Deposit @relation(fields: [transactionId], references: [transactionId]) - transactionId Int @unique - paymentIntentId String @unique - clientSecret String @unique +model Payment { + id Int @id @default(autoincrement()) + // The amount that was requested for this payment + // Use to confirm that the correct amount was captured + amount Int + // The amount of fees this payment incurred + fees Int? + // Lifecycle of the payment + status PaymentStatus + // Which service we should call and listen to + provider PaymentProvider + // If the payment provider is Stripe, then the stripe + // payment model holds the details for the payment intent + paymentIntentId String? @unique + // The key the fronted uses to confirm the payment intent + clientSecret String? @unique + // The reason for the payment, displayed on the stripe dashboard + description String + // The text displayed on the bank statement + descriptor String + // The ledger account responsible for creating the payment + // May be null if user without account is paying + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int? + // Which ledger entries have used this payment + // Useful in case payment goes through, + // then user can be credited unused amount + // into their account. + ledgerEntries LedgerEntry[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } -// TODO: Implement Vipps deposit -// model VippsDeposit { -// ... +// // The type of a ledger entry determines how it is validated. +// // NOTE: In the context of ledger "credit" means to receive money and "debit" means to loose money +// enum LedgerEntryType { +// // The entry must reference a account +// // No other special requirements +// INTERNAL_CREDIT +// // The entry must reference a account and that account +// // must have a non-negative balance after the transaction. +// INTERNAL_DEBIT +// // Represents money coming into the system. +// // The entry must reference a succeed payment with +// // enough value to cover the amount. +// EXTERNAL_DEBIT +// // Represents money going out of the system. +// // No special requirements. +// EXTERNAL_CREDIT // } - -model Payout { - transaction Transaction @relation(fields: [transactionId], references: [id]) - transactionId Int @unique - accountNumber String // For display only -} From 88e947f76ecb320967b422c3e6ac21975cfe8d67 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 10 Aug 2025 11:16:15 +0200 Subject: [PATCH 10/62] feat: payment service --- src/services/ledger/payments/methods.ts | 108 +++++++++++++++++ .../validation.ts => payments/schemas.ts} | 0 .../ledger/payments/stripeWebhookCallback.ts | 110 ++++++++++++++++++ src/services/ledger/stripeEvent/methods.ts | 11 -- .../ledger/transactions/deposits/config.ts | 1 - .../ledger/transactions/deposits/methods.ts | 83 ------------- .../ledger/transactions/deposits/schemas.ts | 8 -- src/services/ledger/transactions/methods.ts | 36 ------ .../ledger/transactions/payment/methods.ts | 49 -------- .../ledger/transactions/payment/schemas.ts | 8 -- .../ledger/transactions/payouts/methods.ts | 54 --------- .../ledger/transactions/payouts/schemas.ts | 7 -- 12 files changed, 218 insertions(+), 257 deletions(-) create mode 100644 src/services/ledger/payments/methods.ts rename src/services/ledger/{stripeEvent/validation.ts => payments/schemas.ts} (100%) create mode 100644 src/services/ledger/payments/stripeWebhookCallback.ts delete mode 100644 src/services/ledger/stripeEvent/methods.ts delete mode 100644 src/services/ledger/transactions/deposits/config.ts delete mode 100644 src/services/ledger/transactions/deposits/methods.ts delete mode 100644 src/services/ledger/transactions/deposits/schemas.ts delete mode 100644 src/services/ledger/transactions/methods.ts delete mode 100644 src/services/ledger/transactions/payment/methods.ts delete mode 100644 src/services/ledger/transactions/payment/schemas.ts delete mode 100644 src/services/ledger/transactions/payouts/methods.ts delete mode 100644 src/services/ledger/transactions/payouts/schemas.ts diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts new file mode 100644 index 000000000..4ae7be7e4 --- /dev/null +++ b/src/services/ledger/payments/methods.ts @@ -0,0 +1,108 @@ +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({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + amount: z.number(), + provider: z.nativeEnum(PaymentProvider), + description: z.string(), + descriptor: z.string().max(22), + ledgerAccountId: z.number().optional(), + }), + method: async ({ prisma, params }) => { + return prisma.payment.create({ + data: { + ...params, + // Manual payments are automatically succeeded + status: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', + } + }) + }, + }) + + /** + * 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({ + auther: () => 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: { + amount: true, + provider: true, + status: true, + description: true, + descriptor: true, + }, + }) + + if (payment.status !== '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.amount, + currency: 'nok', + description: payment.description, + statement_descriptor_suffix: payment.descriptor, + // 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: { + paymentIntentId: paymentIntent.id, + status: 'PROCESSING', + }, + }) + + default: + throw new ServerError('SERVER ERROR', 'Prøvde å forespørre betalingsleverandør som ikke er støttet.') + } + }, + }) +} diff --git a/src/services/ledger/stripeEvent/validation.ts b/src/services/ledger/payments/schemas.ts similarity index 100% rename from src/services/ledger/stripeEvent/validation.ts rename to src/services/ledger/payments/schemas.ts diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts new file mode 100644 index 000000000..b89eda9df --- /dev/null +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -0,0 +1,110 @@ +import type Stripe from "stripe" +import prisma from "@/prisma" +import logger from "@/lib/logger" +import { PaymentStatus } from "@prisma/client" +import { stripe } from "@/lib/stripe" + +/** + * 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': ${latest_charge}`) + 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': ${balance_transaction}`) + return null + } + + return balanceTransaction +} + +// Map between Stripe event types and our internal payment statuses. +const EVENT_TYPE_TO_STATUS: 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 paymentStatus = EVENT_TYPE_TO_STATUS[event.type]; + + if (!paymentStatus) { + 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 payment = await prisma.payment.update({ + where: { + paymentIntentId: paymentIntent.id, + status: { + // Guard against changing final status + // This should never happen, but you can never be too careful + in: ['PENDING', 'PROCESSING', paymentStatus] + }, + }, + data: { + fees: fee, + status: paymentStatus, + }, + select: { + id: 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-${payment.id}` }) + } + + return new Response('', { status: 200 }) +} diff --git a/src/services/ledger/stripeEvent/methods.ts b/src/services/ledger/stripeEvent/methods.ts deleted file mode 100644 index 257b5322f..000000000 --- a/src/services/ledger/stripeEvent/methods.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RequireNothing } from '@/auth/auther/RequireNothing' -import { ServiceMethod } from '@/services/ServiceMethod' - -export namespace StripeEventMethods { - export const handleEvent = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), - // TODO: Find out to how to validate stripe data - method: async ({}) => { - } - }) -} diff --git a/src/services/ledger/transactions/deposits/config.ts b/src/services/ledger/transactions/deposits/config.ts deleted file mode 100644 index 366b7a5a7..000000000 --- a/src/services/ledger/transactions/deposits/config.ts +++ /dev/null @@ -1 +0,0 @@ -export const minimumAmount = 5000 // 50 kr diff --git a/src/services/ledger/transactions/deposits/methods.ts b/src/services/ledger/transactions/deposits/methods.ts deleted file mode 100644 index c8bb449a9..000000000 --- a/src/services/ledger/transactions/deposits/methods.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { DepositSchemas } from './schemas' -import { RequireNothing } from '@/auth/auther/RequireNothing' -import { ServiceMethod } from '@/services/ServiceMethod' -import { stripe } from '@/lib/stripe' -import { ServerError } from '@/services/error' -import { z } from 'zod' - -export namespace DepositMethods { - /** - * Creates a new stripe deposit and a new Stripe payment intent with the specified amount. - */ - export const createStripe = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - paramsSchema: z.object({ - accountId: z.number(), - }), - dataSchema: DepositSchemas.create, - method: async ({ prisma, params, data }) => { - const paymentIntent = await stripe.paymentIntents.create({ - amount: data.amount, - currency: 'nok', - description: 'Innskudd', - statement_descriptor: 'Omegaveven Innskudd', - }) - - if (paymentIntent.client_secret === null) { - throw new ServerError('UNKNOWN ERROR', 'Noe gikk galt med forespørselen til Stripe.') - } - - return prisma.stripeDeposit.create({ - data: { - clientSecret: paymentIntent.client_secret, - paymentIntentId: paymentIntent.id, - deposit: { - create: { - type: 'STRIPE', - transaction: { - create: { - status: 'PENDING', - type: 'DEPOSIT', - toAccountId: params.accountId, - amount: data.amount, - } - } - } - } - } - }) - }, - }) - - /** - * Confirms a Stripe deposit by updating the transaction status to SUCCEEDED. - * This method also retrieves and sets the fee from the Stripe balance transaction. - */ - export const confirmStripe = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - paramsSchema: z.object({ - balanceTransactionId: z.string(), - paymentIntentId: z.string(), - }), - method: async ({ prisma, params }) => { - const { transactionId } = await prisma.stripeDeposit.findUniqueOrThrow({ - where: { - paymentIntentId: params.paymentIntentId, - } - }) - - const balanceTransaction = await stripe.balanceTransactions.retrieve(params.balanceTransactionId) - - await prisma.transaction.update({ - where: { - id: transactionId, - status: 'PENDING', - }, - data: { - status: 'SUCCEEDED', - fee: balanceTransaction.fee, - } - }) - }, - }) -} diff --git a/src/services/ledger/transactions/deposits/schemas.ts b/src/services/ledger/transactions/deposits/schemas.ts deleted file mode 100644 index ba215089f..000000000 --- a/src/services/ledger/transactions/deposits/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { minimumAmount } from './config' -import { z } from 'zod' - -export namespace DepositSchemas { - export const create = z.object({ - amount: z.coerce.number().int().positive().gte(minimumAmount, `Innskudd må være på minst ${minimumAmount}.`), - }) -} diff --git a/src/services/ledger/transactions/methods.ts b/src/services/ledger/transactions/methods.ts deleted file mode 100644 index bfd5387b2..000000000 --- a/src/services/ledger/transactions/methods.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RequireNothing } from '@/auth/auther/RequireNothing' -import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' -import { readPageInputSchemaObject } from '@/lib/paging/schema' -import { ServiceMethod } from '@/services/ServiceMethod' -import { z } from 'zod' - -export namespace TransactionMethods { - export const readPage = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), - paramsSchema: readPageInputSchemaObject( - z.number(), - z.object({ - id: z.number(), - }), - z.object({ - accountId: z.number(), - }), - ), - method: async ({ prisma, params }) => prisma.transaction.findMany({ - where: { - OR: [ - { fromAccountId: params.paging.details.accountId }, - { toAccountId: params.paging.details.accountId }, - ] - }, - include: { - deposit: true, - payout: true, - }, - orderBy: { - createdAt: 'desc', - }, - ...cursorPageingSelection(params.paging.page) - }) - }) -} diff --git a/src/services/ledger/transactions/payment/methods.ts b/src/services/ledger/transactions/payment/methods.ts deleted file mode 100644 index df69c6db6..000000000 --- a/src/services/ledger/transactions/payment/methods.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { PaymentSchemas } from './schemas' -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' -import { RequireNothing } from '@/auth/auther/RequireNothing' -import { ServiceMethod } from '@/services/ServiceMethod' -import { ServerError } from '@/services/error' -import { z } from 'zod' - -export namespace PaymentMethods { - export const create = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - paramsSchema: z.object({ - fromAccountId: z.number(), - }), - dataSchema: PaymentSchemas.create, - opensTransaction: true, - method: async ({ prisma, session, params, data }) => { - if (params.fromAccountId === data.toAccountId) { - throw new ServerError('BAD DATA', 'Overføring til samme konto er ikke tillat.') - } - - return prisma.$transaction(async (tx) => { - await tx.transaction.create({ - data: { - status: 'SUCCEEDED', - type: 'PAYMENT', - fromAccountId: params.fromAccountId, - toAccountId: data.toAccountId, - amount: data.amount, - }, - }) - - const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ - params: { - id: params.fromAccountId, - }, - session, - }) - - if (newBalancee.total < 0) { - throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') - } - - if (newBalancee.fees < 0) { - throw new ServerError('BAD DATA', 'Kontoen skylder ikke nok avgifter for å utføre transaksjonsgebyret.') - } - }) - }, - }) -} diff --git a/src/services/ledger/transactions/payment/schemas.ts b/src/services/ledger/transactions/payment/schemas.ts deleted file mode 100644 index 6d9a645de..000000000 --- a/src/services/ledger/transactions/payment/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod' - -export namespace PaymentSchemas { - export const create = z.object({ - amount: z.coerce.number().int().positive(), - toAccountId: z.coerce.number().int(), - }) -} diff --git a/src/services/ledger/transactions/payouts/methods.ts b/src/services/ledger/transactions/payouts/methods.ts deleted file mode 100644 index d180c6c86..000000000 --- a/src/services/ledger/transactions/payouts/methods.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { PayoutSchemas } from './schemas' -import { RequireNothing } from '@/auth/auther/RequireNothing' -import { ServiceMethod } from '@/services/ServiceMethod' -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' -import { ServerError } from '@/services/error' -import { z } from 'zod' - -export namespace PayoutMethods { - export const create = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - paramsSchema: z.object({ - accountId: z.number(), - }), - dataSchema: PayoutSchemas.create, - opensTransaction: true, - method: async ({ prisma, session, params, data }) => prisma.$transaction(async (tx) => { - const originalBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ - params: { - id: params.accountId, - }, - session, - }) - - const feesToYoink = Math.round((data.amount / originalBalancee.total) * originalBalancee.fees) - - const payout = await tx.transaction.create({ - data: { - status: 'SUCCEEDED', - type: 'PAYOUT', - fee: feesToYoink, - fromAccountId: params.accountId, - amount: data.amount, - } - }) - - const newBalancee = await LedgerAccountMethods.calculateBalance.client(tx).execute({ - params: { - id: params.accountId, - }, - session, - }) - - if (newBalancee.total < 0) { - throw new ServerError('BAD DATA', 'Kontoen har ikke nok penger for å utføre tranaksjonen.') - } - - if (newBalancee.fees < 0) { - throw new ServerError('BAD DATA', 'Dette burde ikke være mulig...') - } - - return payout - }), - }) -} diff --git a/src/services/ledger/transactions/payouts/schemas.ts b/src/services/ledger/transactions/payouts/schemas.ts deleted file mode 100644 index 4e0fa3b29..000000000 --- a/src/services/ledger/transactions/payouts/schemas.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { z } from 'zod' - -export namespace PayoutSchemas { - export const create = z.object({ - amount: z.coerce.number().int().positive(), - }) -} From 0e869941729945295d6210c5644bcf55d6401ed9 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 11 Aug 2025 20:07:48 +0200 Subject: [PATCH 11/62] feat: balance calculation --- src/services/ledger/ledgerAccount/methods.ts | 198 +++++++++++++++---- 1 file changed, 162 insertions(+), 36 deletions(-) diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 7a2439268..33a13bbac 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -3,7 +3,6 @@ import { RequireNothing } from '@/auth/auther/RequireNothing' import { ServiceMethod } from '@/services/ServiceMethod' import { ServerError } from '@/services/error' import { z } from 'zod' -import type { Prisma } from '@prisma/client' export namespace LedgerAccountMethods { /** @@ -82,55 +81,182 @@ export namespace LedgerAccountMethods { }) /** - * Calculates the balance of an account. + * Calculates the balance and fees of a ledger account. Optionally takes a transaction ID to calculate the balance up until that transaction. * - * @param params.id The ID of the account to calculate the balance for. + * @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 balance of the account. + * @returns The balances of the ledger accounts. */ - export const calculateBalance = ServiceMethod({ + export const calculateBalances = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ - id: z.number(), + ids: z.number().array(), + atTransactionId: z.number().optional(), }), method: async ({ prisma, params }) => { - // Since we can't know if the account exists from the aggregate queries, we need to check it manually first. - const accountExists = await prisma.ledgerAccount.count({ + const balanceArray = await prisma.ledgerEntry.groupBy({ + by: ['ledgerAccountId'], where: { - id: params.id, + // Select which accounts we want to calculate the balance for + ledgerAccountId: { + not: null, + 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). + amount: { gt: 0 }, + // The receiver should (logically) only receive the money if the transaction succeeded. + ledgerTransaction: { status: 'SUCCEEDED' }, + }, + { + // If the amount is less than zero the entry is a debit (i.e. taking money). + amount: { 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: { status: { in: ['PENDING', 'SUCCEEDED'] } }, + }, + ], + }, + // Select what fields we should sum + _sum: { + amount: true, + fees: true, }, }) - if (!accountExists) { - throw new ServerError('NOT FOUND', 'Kontoen eksisterer ikke.') - } + // The output from the Prisma `groupBy` method is an array. + // We convert it to an object (record) as it is more sensible for lookups. + const balanceRecord = Object.fromEntries( + // The "as const"s are required so that TS understands that the arrays + // have a length of exactly two. + [ + // Set all ids to zero by default in case some ledger accounts do not have + // any ledger entries yet. + ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), + // Map the array returned by `groupBy` to key value pairs. + ...balanceArray.map(balance => [ + // The "!" is required because the typing for `fromEntries` doesn't accept + // null as a key. (Even though all keys just get converted to strings during + // runtime.) The query above guarantees that the id can never be null so + // its safe anyhow. + balance.ledgerAccountId!, + { + // Prisma sets sum to "null" in case the only rows which exist are null. + // For our case we can treat it as zero. + amount: balance._sum.amount ?? 0, + fees: balance._sum.fees ?? 0, + }, + ] as const), + ] + ) - const sumTransactions = async (where: Partial) => { - const result = await prisma.transaction.aggregate({ - _sum: { - amount: true, - fee: true, - }, - where: { - ...where, - status: 'SUCCEEDED', - }, - }) - return { - total: result._sum.amount ?? 0, - fees: result._sum.fee ?? 0, - } - } + // The object returned by `fromEntries` assumes that all keys map to the provided type. + // This is obviously not true so we need to assert the record as partial. + return balanceRecord as Partial + } + }) - const [sumIn, sumOut] = await Promise.all([ - sumTransactions({ toAccountId: params.id }), - sumTransactions({ fromAccountId: params.id }), - ]) + /** + * 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({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + atTransactionId: z.number().optional(), + }), + method: async ({ prisma, session, params }) => { + const balances = await calculateBalances.client(prisma).execute({ + params: { + ids: [params.id], + atTransactionId: params.atTransactionId, + }, + session, + }) - return { - total: sumIn.total - sumOut.total, - fees: sumIn.fees - sumOut.fees, - } + // We know that the returned balances must contain the id we provided. + // So, we can simply assert that this is not undefined. + // TODO: There might be a better way to do this? + return balances[params.id]! } }) + + // const sumPayments = async () => { + // const payments = await prisma.payment.aggregate({ + // where: { + // transaction: { + // id: params.atTransactionId && { lte: params.atTransactionId }, + // toAccountId: params.id, + // status: 'SUCCEEDED', + // } + // }, + // _sum: { + // amount: true, + // fees: true, + // }, + // }) + + // return { + // amount: payments._sum.amount ?? 0, + // fees: payments._sum.fees ?? 0, + // } + // } + + // const sumTransfers = async (direction: 'TO' | 'FROM', accountId: number) => { + // const transactionFilter = { + // id: params.atTransactionId && { lte: params.atTransactionId }, + // fromAccountId: direction === 'FROM' ? accountId : undefined, + // toAccountId: direction === 'TO' ? accountId : undefined, + // // Reserve transfers that are pending (i.e. don't count them towards the balance), + // // but don't make them available at the destination account + // status: direction === 'TO' ? 'SUCCEEDED' : { in: ['SUCCEEDED', 'PENDING'] }, + // } satisfies Prisma.TransactionWhereInput + + // const transfers = await prisma.transfer.aggregate({ + // where: { + // transaction: transactionFilter, + // }, + // _sum: { + // amount: true, + // fees: true, + // }, + // }) + + // return { + // amount: transfers._sum.amount ?? 0, + // fees: transfers._sum.fees ?? 0, + // } + // } + + // const [payments, transfersIn, transfersOut] = await Promise.all([ + // sumPayments(), + // sumTransfers('TO', params.id), + // sumTransfers('FROM', params.id), + // ]) + + // return { + // amount: payments.amount + transfersIn.amount - transfersOut.amount, + // fees: payments.fees + transfersIn.fees - transfersOut.fees, + // } + // } + // }) } From 7d7f43bdef5346a625023c409fe3883f89d7ccb5 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Fri, 15 Aug 2025 15:07:06 +0200 Subject: [PATCH 12/62] refactor: simplify relation between transaction and entries/payment/payout --- src/prisma/schema/ledger.prisma | 59 ++++++++++------- src/services/ledger/ledgerAccount/methods.ts | 67 ++++++++++++-------- 2 files changed, 77 insertions(+), 49 deletions(-) diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 3b69983e9..aeed324d6 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -51,14 +51,20 @@ enum LedgerTransactionStatus { CANCELED } -// The system uses double-entry accounting (google it or read the wiki to know what this means) -// Each transaction consists of one or more LedgerEntries that must sum to zero -// Either all ledger entires in a transaction are valid or none are +// 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 status LedgerTransactionStatus ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique + payout Payout? @relation(fields: [payoutId], references: [id]) + payoutId Int? @unique // Relevant relations to other tables based un purpose // purchase Purchase @@ -70,7 +76,6 @@ model LedgerTransaction { updatedAt DateTime @updatedAt } -// All ledger entries part of a transaction must sum to zero model LedgerEntry { id Int @id @default(autoincrement()) // The amount this ledger entry moves @@ -78,17 +83,14 @@ model LedgerEntry { amount 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. Set to 0 explicitly for no fees. + // Must be non-null when completing a transaction. It must be explicitly set to 0 to indicate no fees. fees Int? // What type of movement this ledger entry does. // Determines how it is validated. // type LedgerEntryType // The account which should be credited/debited on completions ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) - ledgerAccountId Int? - // The payment the amount comes from in case this is an external debit - payment Payment? @relation(fields: [paymentId], references: [id]) - paymentId Int? + 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, @@ -105,8 +107,6 @@ model LedgerEntry { // 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]) - // Same applies to payment id and transaction id - @@unique([ledgerTransactionId, paymentId]) } enum PaymentProvider { @@ -130,39 +130,52 @@ enum PaymentStatus { } model Payment { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // The amount that was requested for this payment // Use to confirm that the correct amount was captured - amount Int + amount Int // The amount of fees this payment incurred - fees Int? + fees Int? // Lifecycle of the payment - status PaymentStatus + status PaymentStatus // Which service we should call and listen to - provider PaymentProvider + provider PaymentProvider // If the payment provider is Stripe, then the stripe // payment model holds the details for the payment intent - paymentIntentId String? @unique + paymentIntentId String? @unique // The key the fronted uses to confirm the payment intent - clientSecret String? @unique + clientSecret String? @unique // The reason for the payment, displayed on the stripe dashboard - description String + description String // The text displayed on the bank statement - descriptor String + descriptor String // The ledger account responsible for creating the payment // May be null if user without account is paying - ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) - ledgerAccountId Int? + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int? // Which ledger entries have used this payment // Useful in case payment goes through, // then user can be credited unused amount // into their account. - ledgerEntries LedgerEntry[] + ledgerTransaction LedgerTransaction? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +// Bookkeeping for where money has been sent out from the website +model Payout { + id Int @id @default(autoincrement()) + amount Int + fees Int? + // The bank account number is only for our own bookkeeping. + // The money has to be transferred manually by HS! + bankAccountNumber String? + comment String? + + ledgerTransaction LedgerTransaction? +} + // // The type of a ledger entry determines how it is validated. // // NOTE: In the context of ledger "credit" means to receive money and "debit" means to loose money // enum LedgerEntryType { diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 33a13bbac..dad914892 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -102,7 +102,6 @@ export namespace LedgerAccountMethods { where: { // Select which accounts we want to calculate the balance for ledgerAccountId: { - not: null, in: params.ids, }, // Since transaction ids are sequential we can use the less than operator @@ -136,35 +135,49 @@ export namespace LedgerAccountMethods { }, }) - // The output from the Prisma `groupBy` method is an array. - // We convert it to an object (record) as it is more sensible for lookups. + // 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. const balanceRecord = Object.fromEntries( - // The "as const"s are required so that TS understands that the arrays - // have a length of exactly two. - [ - // Set all ids to zero by default in case some ledger accounts do not have - // any ledger entries yet. - ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), - // Map the array returned by `groupBy` to key value pairs. - ...balanceArray.map(balance => [ - // The "!" is required because the typing for `fromEntries` doesn't accept - // null as a key. (Even though all keys just get converted to strings during - // runtime.) The query above guarantees that the id can never be null so - // its safe anyhow. - balance.ledgerAccountId!, - { - // Prisma sets sum to "null" in case the only rows which exist are null. - // For our case we can treat it as zero. - amount: balance._sum.amount ?? 0, - fees: balance._sum.fees ?? 0, - }, - ] as const), - ] + balanceArray.map(balance => [ + balance.ledgerAccountId, + { + amount: balance._sum.amount ?? 0, + fees: balance._sum.fees ?? 0 + } + ]) ) + return balanceRecord + + // // The output from the Prisma `groupBy` method is an array. + // // We convert it to an object (record) as it is more sensible for lookups. + // const balanceRecord = Object.fromEntries( + // // The "as const"s are required so that TS understands that the arrays + // // have a length of exactly two. + // [ + // // Set all ids to zero by default in case some ledger accounts do not have + // // any ledger entries yet. + // ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), + // // Map the array returned by `groupBy` to key value pairs. + // ...balanceArray.map(balance => [ + // // The "!" is required because the typing for `fromEntries` doesn't accept + // // null as a key. (Even though all keys just get converted to strings during + // // runtime.) The query above guarantees that the id can never be null so + // // its safe anyhow. + // balance.ledgerAccountId!, + // { + // // Prisma sets sum to "null" in case the only rows which exist are null. + // // For our case we can treat it as zero. + // amount: balance._sum.amount ?? 0, + // fees: balance._sum.fees ?? 0, + // }, + // ] as const), + // ] + // ) + // The object returned by `fromEntries` assumes that all keys map to the provided type. // This is obviously not true so we need to assert the record as partial. - return balanceRecord as Partial + // return balanceRecord as Partial } }) @@ -193,10 +206,12 @@ export namespace LedgerAccountMethods { session, }) + return balances[0] + // We know that the returned balances must contain the id we provided. // So, we can simply assert that this is not undefined. // TODO: There might be a better way to do this? - return balances[params.id]! + // return balances[params.id]! } }) From 5b82357443ede2b905a2697aeee2ed97ee8d008d Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Fri, 15 Aug 2025 15:07:36 +0200 Subject: [PATCH 13/62] feat: transaction validation logic --- .../ledger/ledgerTransactions/methods.ts | 519 ++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 src/services/ledger/ledgerTransactions/methods.ts diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts new file mode 100644 index 000000000..f588b0991 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -0,0 +1,519 @@ +import { RequireNothing } from '@/auth/auther/RequireNothing' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { ServiceMethod } from '@/services/ServiceMethod' +import { LedgerTransactionPurpose } from '@prisma/client' +import type { Prisma, LedgerTransactionStatus, PaymentStatus } from '@prisma/client' +import { z } from 'zod' +import { LedgerAccountMethods } from '../ledgerAccount/methods' + +export namespace LedgerTransactionMethods { + // TODO FOR IMORGEN: + // - Splitte validate transaction i to + // - Finne ut hvordan man setter sammen balanse kalkulasjon + // - Integrere med shop hvis tid + // - Se litt på frontend hvis gidder + + /** + * Checks that the participating attachments have enough balance to do a given transaction. + */ + async function determineTransactionStatus(prisma: Prisma.TransactionClient, id: number): Promise { + const { ledgerEntries, payment, payout, status } = await prisma.ledgerTransaction.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + ledgerEntries: true, + payment: true, + payout: true, + }, + }) + + // All the rules for a transaction to be valid are written in styled boxes. + + // NOTE: The order of the rules are important! + + /////////////////////////////////////////////////////////////////////// + // A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) // + // can never change state, // + /////////////////////////////////////////////////////////////////////// + + if (status !== 'PENDING') return status + + /////////////////////////////////////////////////////////////////// + // If any payment has failed, the entire transaction has failed. // + /////////////////////////////////////////////////////////////////// + + const okStates: PaymentStatus[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] + const hasFailedPayment = payment && !okStates.includes(payment.status) + + if (hasFailedPayment) { + return 'FAILED' + } + + /////////////////////////////////////////////////////////////////// + // Payments and payouts may only have positive amounts and fees. // + /////////////////////////////////////////////////////////////////// + + const validPayment = !payment || (payment.amount > 0 && (!payment.fees || payment.fees > 0)) + const validPayout = !payout || (payout.amount > 0 && (!payout.fees || payout.fees > 0)) + + if (!validPayout || !validPayment) { + return 'FAILED' + } + + ///////////////////////////////////////////////////////////////////////////////////// + // If amount and fees are both non-zero in an entry, they must have the same sign. // + ///////////////////////////////////////////////////////////////////////////////////// + + const validLedgerEntries = ledgerEntries.every(entry => + !entry.amount || !entry.fees || Math.sign(entry.amount) === Math.sign(entry.fees) + ) + + if (!validLedgerEntries) { + return 'FAILED' + } + + ///////////////////////////////////////////////////////////////// + // Kirchhoff's first law! The sum of all amounts must be zero. // + // I.e. money must come from somewhere and go to somewhere. // + ///////////////////////////////////////////////////////////////// + + // NOTE: Since the number of entries in a transaction is generally low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const ledgerEntriesAmountSum = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0) + const paymentAmount = payment?.amount ?? 0 + const payoutAmount = payout?.amount ?? 0 + + if (ledgerEntriesAmountSum + payoutAmount !== paymentAmount) { + return 'FAILED' + } + + //////////////////////////////////////////////////////////////////// + // If an entry is debit (amount < 0), its referenced account must // + // have a positive balance after the transaction succeeds. // + //////////////////////////////////////////////////////////////////// + + const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.amount < 0).map(entry => entry.ledgerAccountId) + + if (debitLedgerAccountIds) { + const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ + params: { + ids: debitLedgerAccountIds, + atTransactionId: id, + }, + session: null, + }) + + const hasNegativeBalance = Object.values(balances).some(balance => balance.amount < 0 || balance.fees < 0) + + if (hasNegativeBalance) { + return 'FAILED' + } + } + + //////////////////////////////////////////////////////////// + // If any payment is pending, the transaction is pending. // + //////////////////////////////////////////////////////////// + + // Since we have checked for failure states above, + // we can simply check that the transaction has not succeeded. + const hasPendingPayment = payment && payment.status !== 'SUCCEEDED' + + if (hasPendingPayment) { + return 'PENDING' + } + + // NOTE: Since fees are not known until the payment (if any) completes, + // the checks must be run afterwards. + + //////////////////////////////// + // All fees must be non-null. // + //////////////////////////////// + + const hasNullFees = + ledgerEntries.some(entry => entry.fees === null) || + payment && payment.fees === null || + payout && payout.fees === null + + if (hasNullFees) { + return 'FAILED' + } + + ////////////////////////////////////////////////// + // Fees must also follow Kirchhoff's first law. // + ////////////////////////////////////////////////// + + // NOTE: Since the number of entries in a transaction is generally low (max two) we can + // sum the amounts and fees in memory rather than doing a database aggregation. + const ledgerEntriesFeesSum = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = payment?.fees ?? 0 + const payoutFees = payout?.fees ?? 0 + + if (ledgerEntriesFeesSum + payoutFees !== paymentFees) { + return 'FAILED' + } + + return 'SUCCEEDED' + } + + export const read = ServiceMethod({ + auther: () => 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: true, + payout: true, + }, + }) + + return transaction + } + }) + + export const readPage = ServiceMethod({ + auther: () => 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: true, + payout: true, + }, + orderBy: { + createdAt: 'desc', + id: 'desc', + }, + ...cursorPageingSelection(params.paging.page) + }) + }) + + export const create = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO, + paramsSchema: z.object({ + purpose: z.nativeEnum(LedgerTransactionPurpose), + ledgerEntries: z.object({ + amount: z.number(), + ledgerAccountId: z.number(), + }).array(), + paymentId: z.number().optional(), + payoutId: z.number().optional(), + }), + method: async ({ prisma, session, params }, ) => { + const { id } = await prisma.ledgerTransaction.create({ + data: { + purpose: params.purpose, + status: 'PENDING', + ledgerEntries: { + create: params.ledgerEntries, + }, + paymentId: params.paymentId, + payoutId: params.payoutId, + }, + select: { + id: true, + }, + }) + + const transaction = await updateStatus.client(prisma).execute({ + params: { + id, + }, + session, + }) + + return transaction + } + }) + + /** + * Tries to update a given transaction to a state. + * If the transaction is valid and possible (all payments completed, enough balance, etc...) the state is set to succeeded. + * If anything has failed + */ + export const updateStatus = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + id: z.number(), + }), + method: async ({ session, prisma, params}) => { + const transactionStatus = await determineTransactionStatus(prisma, params.id) + + // 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, + status: 'PENDING', + }, + data: { + status: transactionStatus, + }, + }) + + return await read.client(prisma).execute({ + params: { + id: params.id, + }, + session, + }) + } + }) +} + + // MIGHT NOT BE NEEDED + /** + * Cancel a pending transaction. If the payment is processing it will be cancel + */ + // export const cancel = ServiceMethod({ + // auther: () => RequireNothing.staticFields({}).dynamicFields({}), + // method: async () => { + + // } + // }) + +/* + + + /////////////////////////////////////////////////////////////////////////////////////// + // All ledger entries must have either a ledger account, payment or payout attached. // + // Multiple or none are not allowed. (The money must come from/go to somewhere.) // + /////////////////////////////////////////////////////////////////////////////////////// + + // Utility function for checking that an object has exactly one of the provided keys + function exactlyOneOf(obj: T, keys: (keyof T)[]): boolean { + return keys.filter(key => obj[key] !== null).length === 1 + } + + const onlySingleAttachments = transaction.ledgerEntries.every( + entry => exactlyOneOf(entry, ['ledgerAccountId', 'paymentId', 'payoutId']) + ) + + if (!onlySingleAttachments) { + return 'FAILED' + } + +// The output from the Prisma `groupBy` method is an array. + // We convert it to an object (record) as it is more sensible for lookups. + const balanceRecord = Object.fromEntries( + // The "as const"s are required so that TS understands that the arrays + // have a length of exactly two. + [ + // Set all ids to zero by default in case some ledger accounts do not have + // any ledger entries yet. + ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), + // Map the array returned by `groupBy` to key value pairs. + ...balanceArray.map(balance => [ + // The "!" is required because the typing for `fromEntries` doesn't accept + // null as a key. (Even though all keys just get converted to strings during + // runtime.) The query above guarantees that the id can never be null so + // its safe anyhow. + balance.ledgerAccountId!, + { + // Prisma sets sum to "null" in case the only rows which exist are null. + // For our case we can treat it as zero. + amount: balance._sum.amount ?? 0, + fees: balance._sum.fees ?? 0, + }, + ] as const), + ] + ) + +// The object returned by `fromEntries` assumes that all keys map to the provided type. +// This is obviously not true so we need to assert the record as partial. + +async function createPurchase() { + const [transaction, purchase] = await prisma.$transaction(async (tx) => { + // Call stripe api and do internal money transfer + const createTransaction({ + ledgerEntries: [ + { + accountId: sellerAccountId, + amount: productPrice, + }, + { + accountId: buyerAccountId, + amount: -productPrice, + }, + ], + }) + + // Create record of what should be bought + const purchase = purchases.create({ + productsPurchases: [{productId: ..., price: ..., quantity: ...}, ...], + userId: ..., + transactionId: transaction.id, + }) + + return [transaction, purchase] + }) + + return [transaction, purchase] +} + +*/ + +/* + +async function createDeposit(...) { + const paymentId = cuid() + + await prisma.$transaction(async (tx) => { + // Create the transaction + // The entries must always sum to zero - money cannot come from nowhere + await createTransaction({ + client: tx, + purpose: 'DEPOSIT', + entries: [ + { + amount: depositAmount, + accountId, + }, + { + amount: -depositAmount, + paymentId, + create: true, + }, + ], + }) + + return payment + }) + + // Call the Stripe API and return the client secret so the user can complete the transaction + return await initiatePayment({ + // Implicitly global prisma + paymentId: payment.id + }) +} + +*/ + +// POST /purchase + +// async function createPurchase() { +// const transaction = await prisma.$transaction(async (tx) => { +// // Create record of what should be bough20222t +// const purchase = purchases.create({ +// productsPurchases: [{productId: ..., price: ..., quantity: ...}, ...], +// userId: ..., +// }) + +// // Call stripe api and do internal money transfer +// return await transactios.create({ +// totalAmount: ..., +// toAccountId: ..., +// fromAccountId: ..., +// purpose: 'PURCHASE', +// purchaseId: purchase.id, +// }) +// }) +// } + +// async function createEventPayment() { +// await prisma.$transaction(async (tx) => { +// return await transactios.create({ +// totalAmount: ..., +// toAccountId: ..., +// fromAccountId: ..., +// purpose: 'EVENT_PAYMENT', +// eventRegistrations: eventRegistration.id, +// }) +// }) +// } + +// async function createDeposit() { +// const paymentIntent = await stripe.paymentIntents.create({ + +// }) + +// await prisma.$transaction(async (tx) => { +// return await transactios.create({ +// totalAmount: ..., +// toAccountId: ..., +// purpose: 'DEPOSIT', +// }) +// }) +// } + +// async function createPayout() { +// await prisma.$transaction(async (tx) => { +// return await transactios.create({ +// toAccountId: null, +// fromAccountId: ..., +// totalAmount: ..., +// purpose: 'PAYOUT', +// }) +// }) +// } + +/* +// Checks if the source account has enough balance and that the payment (if present) is successful. + async function validateTransaction(prisma: Prisma.TransactionClient, id: number): Promise { + const transaction = await prisma.ledgerTransaction.findUniqueOrThrow({ + where: { id }, + // Only query the fields we need for validation + select: { + id: true, + fromAccountId: true, + status: true, + payment: { + select: { + status: true, + } + }, + transfer: { + // We only need to know if transfer is present or not + select: {}, + }, + } + }) + + // A failed transaction can never be valid + if (transaction.status === 'FAILED') return false + + // A transaction is not valid until its underlying payment is successful (if it has one) + if (transaction.payment?.status !== 'SUCCEEDED') return false + + // A transaction with a transfer must... + if (transaction.transfer !== null) { + // ...have a fromAccount (the money must come from somewhere) + if (transaction.fromAccountId === null) return false + + const fromAccountBalance = await LedgerAccountMethods.calculateBalance.client(prisma).execute({ + params: { + id: transaction.fromAccountId, + atTransactionId: transaction.id, + }, + bypassAuth: true, + session: null, + }) + + // ...and result in a non-negative balance for the fromAccount + if (fromAccountBalance.amount < 0) return false + } + + return true + } +*/ \ No newline at end of file From 3d6e24e5f6a4a549294ce9ed04a9d6d003273544 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 20 Aug 2025 21:11:48 +0200 Subject: [PATCH 14/62] feat: more ledger transaction validation --- src/services/ledger/ledgerAccount/Types.ts | 9 + src/services/ledger/ledgerAccount/methods.ts | 5 +- .../ledger/ledgerTransactions/Type.ts | 9 + .../ledgerTransactions/calculateFees.ts | 84 ++++++ .../determineTransactionState.ts | 132 +++++++++ .../ledger/ledgerTransactions/methods.ts | 274 +++++++----------- .../ledger/manualTransfers/methods.ts | 16 + 7 files changed, 359 insertions(+), 170 deletions(-) create mode 100644 src/services/ledger/ledgerAccount/Types.ts create mode 100644 src/services/ledger/ledgerTransactions/Type.ts create mode 100644 src/services/ledger/ledgerTransactions/calculateFees.ts create mode 100644 src/services/ledger/ledgerTransactions/determineTransactionState.ts create mode 100644 src/services/ledger/manualTransfers/methods.ts diff --git a/src/services/ledger/ledgerAccount/Types.ts b/src/services/ledger/ledgerAccount/Types.ts new file mode 100644 index 000000000..f85f75f16 --- /dev/null +++ b/src/services/ledger/ledgerAccount/Types.ts @@ -0,0 +1,9 @@ +// 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, +} + +export type BalanceRecord = Record diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index dad914892..a77956327 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -3,6 +3,7 @@ import { RequireNothing } from '@/auth/auther/RequireNothing' import { ServiceMethod } from '@/services/ServiceMethod' import { ServerError } from '@/services/error' import { z } from 'zod' +import { BalanceRecord } from './Types' export namespace LedgerAccountMethods { /** @@ -96,7 +97,7 @@ export namespace LedgerAccountMethods { ids: z.number().array(), atTransactionId: z.number().optional(), }), - method: async ({ prisma, params }) => { + method: async ({ prisma, params }): Promise => { const balanceArray = await prisma.ledgerEntry.groupBy({ by: ['ledgerAccountId'], where: { @@ -206,7 +207,7 @@ export namespace LedgerAccountMethods { session, }) - return balances[0] + return balances[params.id] // We know that the returned balances must contain the id we provided. // So, we can simply assert that this is not undefined. diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/Type.ts new file mode 100644 index 000000000..1d204bf3f --- /dev/null +++ b/src/services/ledger/ledgerTransactions/Type.ts @@ -0,0 +1,9 @@ +import type { Prisma } from "@prisma/client"; + +export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ + include: { + ledgerEntries: true, + payment: true, + manualTransfer: true, + } +}> diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts new file mode 100644 index 000000000..bb6368073 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -0,0 +1,84 @@ +import { ManualTransfer, Payment } from "@prisma/client" +import { 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) { + let fees = Math.floor(totalFees * entryAmount / totalAmount) + + // Guard against NaN + fees ||= 0 + // Ensure fees are never positive + // (Taking money from an account should never increase that account's fees) + fees = Math.min(fees, 0) + // Ensure fees never exceed the account's fee balance + // (We cannot take more fees than an account has) + fees = Math.max(fees, -totalFees) + + return fees +} + +/** + * Calculates the fees for debit ledger entries (amount < 0) based on + * the balances of the accounts which are deducted. + */ +export function calculateDebitFees(ledgerEntries: { amount: number, ledgerAccountId: number }[], balances: BalanceRecord) { + const debitLedgerEntries = ledgerEntries.filter(entry => entry.amount < 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.amount, balance.amount, balance.fees)] + })) +} + +/** + * Calculates the fees for credit ledger entries (amount > 0) based on + * the total amount and total fees of in the transaction. + */ +export function calculateCreditFees( + ledgerEntries: { amount: number, fees: number | null, ledgerAccountId: number }[], + payment: Payment | null, + manualTransfer: ManualTransfer | 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.amount > 0) + const debitLedgerEntries = ledgerEntries.filter(entry => entry.amount < 0) + + const sum = (...values: (number | null | undefined)[]) => + values.reduce((total, value) => total + (value ?? 0), 0) + + let totalAmount = sum( + ...ledgerEntries.map(entry => entry.amount), + payment?.amount, + manualTransfer?.amount, + ) + let totalFees = sum( + // Only debit ledger entries may have fees + ...debitLedgerEntries.map(entry => entry.fees), + payment?.fees, + manualTransfer?.fees, + ) + + return Object.fromEntries(creditLedgerEntries.map(entry => { + const fees = feesFormula(entry.amount, totalAmount, totalFees) + + // Subtract the from the totals to ensure + // that the sum of all fees ends up exactly + // equal to `totalFees`. + totalAmount -= entry.amount + totalFees -= fees + + return [entry.ledgerAccountId, fees] + })) +} \ No newline at end of file diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts new file mode 100644 index 000000000..ed3c6cb21 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -0,0 +1,132 @@ +import { LedgerTransactionStatus, PaymentStatus } from "@prisma/client" +import { ExpandedLedgerTransaction } from "./Type" +import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" + +/** + * 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 = [ + noTerminalState, + noFailedPayment, + amountAndFeesHaveSameSigns, + validAmountSum, + sufficientBalances, + paymentComplete, + noNullFees, + validFeesSum, + ] + + for (const rule of rules) { + const state = await rule(transaction, balances) + + if (state) return state + } + + return 'SUCCEEDED' +} +/** + * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) + * can never change state. + */ +function noTerminalState({ status }: ExpandedLedgerTransaction) { + if (status !== 'PENDING') return status +} + +/** + * If any payment has failed, the entire transaction has failed. + */ +function noFailedPayment({ payment }: ExpandedLedgerTransaction) { + const okStates: PaymentStatus[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] + const hasFailedPayment = payment && !okStates.includes(payment.status) + + if (hasFailedPayment) return 'FAILED' +} + +/** + * 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, manualTransfer }: ExpandedLedgerTransaction) { + // 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 validPayment = sameSigns(payment?.amount, payment?.fees) + const validManualTransfer = sameSigns(manualTransfer?.amount, manualTransfer?.fees) + const validLedgerEntries = ledgerEntries.every(entry => sameSigns(entry.amount, entry.fees)) + + if (!validManualTransfer || !validPayment || !validLedgerEntries) return 'FAILED' +} + + +/** + * 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, manualTransfer }: ExpandedLedgerTransaction) { + // 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 ledgerEntriesAmountSum = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0) + const paymentAmount = payment?.amount ?? 0 + const manualTransferAmount = manualTransfer?.amount ?? 0 + + if (ledgerEntriesAmountSum !== paymentAmount + manualTransferAmount) return 'FAILED' +} + +/** + * If an entry is debit (amount < 0), its referenced account must + * have a positive balance after the transaction succeeds. + */ +async function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balances: BalanceRecord) { + const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.amount < 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 'FAILED' +} + +/** + * If any payment is pending, the transaction is pending. + */ +function paymentComplete({ payment }: ExpandedLedgerTransaction) { + // Since we have checked for failure states above, + // we can simply check that the transaction has not succeeded. + const hasPendingPayment = payment && payment.status !== 'SUCCEEDED' + + if (hasPendingPayment) return 'PENDING' +} + +/** + * All fees must be non-null. + */ +function noNullFees({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction) { + const hasNullFees = + ledgerEntries.some(entry => entry.fees === null) || + payment && payment.fees === null || + manualTransfer && manualTransfer.fees === null + + if (hasNullFees) return 'FAILED' +} + +/** + * Fees must also follow Kirchhoff's first law. + */ +function validFeesSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction) { + // 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 ledgerEntriesFeesSum = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = payment?.fees ?? 0 + const manualTransferFees = manualTransfer?.fees ?? 0 + + if (ledgerEntriesFeesSum !== paymentFees + manualTransferFees) return 'FAILED' +} diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index f588b0991..9573e165b 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -3,159 +3,17 @@ import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' import { readPageInputSchemaObject } from '@/lib/paging/schema' import { ServiceMethod } from '@/services/ServiceMethod' import { LedgerTransactionPurpose } from '@prisma/client' -import type { Prisma, LedgerTransactionStatus, PaymentStatus } from '@prisma/client' +import type { Prisma } from '@prisma/client' import { z } from 'zod' import { LedgerAccountMethods } from '../ledgerAccount/methods' +import { ServerError } from '@/services/error' +import { calculateCreditFees, calculateDebitFees } from './calculateFees' +import { determineTransactionState } from './determineTransactionState' export namespace LedgerTransactionMethods { - // TODO FOR IMORGEN: - // - Splitte validate transaction i to - // - Finne ut hvordan man setter sammen balanse kalkulasjon - // - Integrere med shop hvis tid - // - Se litt på frontend hvis gidder - /** - * Checks that the participating attachments have enough balance to do a given transaction. + * Reads a single transaction including its ledger entries, payment and manual transfer (if any). */ - async function determineTransactionStatus(prisma: Prisma.TransactionClient, id: number): Promise { - const { ledgerEntries, payment, payout, status } = await prisma.ledgerTransaction.findUniqueOrThrow({ - where: { id }, - select: { - status: true, - ledgerEntries: true, - payment: true, - payout: true, - }, - }) - - // All the rules for a transaction to be valid are written in styled boxes. - - // NOTE: The order of the rules are important! - - /////////////////////////////////////////////////////////////////////// - // A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) // - // can never change state, // - /////////////////////////////////////////////////////////////////////// - - if (status !== 'PENDING') return status - - /////////////////////////////////////////////////////////////////// - // If any payment has failed, the entire transaction has failed. // - /////////////////////////////////////////////////////////////////// - - const okStates: PaymentStatus[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] - const hasFailedPayment = payment && !okStates.includes(payment.status) - - if (hasFailedPayment) { - return 'FAILED' - } - - /////////////////////////////////////////////////////////////////// - // Payments and payouts may only have positive amounts and fees. // - /////////////////////////////////////////////////////////////////// - - const validPayment = !payment || (payment.amount > 0 && (!payment.fees || payment.fees > 0)) - const validPayout = !payout || (payout.amount > 0 && (!payout.fees || payout.fees > 0)) - - if (!validPayout || !validPayment) { - return 'FAILED' - } - - ///////////////////////////////////////////////////////////////////////////////////// - // If amount and fees are both non-zero in an entry, they must have the same sign. // - ///////////////////////////////////////////////////////////////////////////////////// - - const validLedgerEntries = ledgerEntries.every(entry => - !entry.amount || !entry.fees || Math.sign(entry.amount) === Math.sign(entry.fees) - ) - - if (!validLedgerEntries) { - return 'FAILED' - } - - ///////////////////////////////////////////////////////////////// - // Kirchhoff's first law! The sum of all amounts must be zero. // - // I.e. money must come from somewhere and go to somewhere. // - ///////////////////////////////////////////////////////////////// - - // NOTE: Since the number of entries in a transaction is generally low (max two) we can - // sum the amounts and fees in memory rather than doing a database aggregation. - const ledgerEntriesAmountSum = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0) - const paymentAmount = payment?.amount ?? 0 - const payoutAmount = payout?.amount ?? 0 - - if (ledgerEntriesAmountSum + payoutAmount !== paymentAmount) { - return 'FAILED' - } - - //////////////////////////////////////////////////////////////////// - // If an entry is debit (amount < 0), its referenced account must // - // have a positive balance after the transaction succeeds. // - //////////////////////////////////////////////////////////////////// - - const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.amount < 0).map(entry => entry.ledgerAccountId) - - if (debitLedgerAccountIds) { - const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ - params: { - ids: debitLedgerAccountIds, - atTransactionId: id, - }, - session: null, - }) - - const hasNegativeBalance = Object.values(balances).some(balance => balance.amount < 0 || balance.fees < 0) - - if (hasNegativeBalance) { - return 'FAILED' - } - } - - //////////////////////////////////////////////////////////// - // If any payment is pending, the transaction is pending. // - //////////////////////////////////////////////////////////// - - // Since we have checked for failure states above, - // we can simply check that the transaction has not succeeded. - const hasPendingPayment = payment && payment.status !== 'SUCCEEDED' - - if (hasPendingPayment) { - return 'PENDING' - } - - // NOTE: Since fees are not known until the payment (if any) completes, - // the checks must be run afterwards. - - //////////////////////////////// - // All fees must be non-null. // - //////////////////////////////// - - const hasNullFees = - ledgerEntries.some(entry => entry.fees === null) || - payment && payment.fees === null || - payout && payout.fees === null - - if (hasNullFees) { - return 'FAILED' - } - - ////////////////////////////////////////////////// - // Fees must also follow Kirchhoff's first law. // - ////////////////////////////////////////////////// - - // NOTE: Since the number of entries in a transaction is generally low (max two) we can - // sum the amounts and fees in memory rather than doing a database aggregation. - const ledgerEntriesFeesSum = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) - const paymentFees = payment?.fees ?? 0 - const payoutFees = payout?.fees ?? 0 - - if (ledgerEntriesFeesSum + payoutFees !== paymentFees) { - return 'FAILED' - } - - return 'SUCCEEDED' - } - export const read = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ @@ -169,7 +27,7 @@ export namespace LedgerTransactionMethods { include: { ledgerEntries: true, payment: true, - payout: true, + manualTransfer: true, }, }) @@ -177,6 +35,9 @@ export namespace LedgerTransactionMethods { } }) + /** + * Read several ledger transactions including its ledger entries, payment and manual transfer (if any). + */ export const readPage = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: readPageInputSchemaObject( @@ -199,7 +60,7 @@ export namespace LedgerTransactionMethods { include: { ledgerEntries: true, payment: true, - payout: true, + manualTransfer: true, }, orderBy: { createdAt: 'desc', @@ -209,6 +70,14 @@ export namespace LedgerTransactionMethods { }) }) + /** + * 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({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO, paramsSchema: z.object({ @@ -218,64 +87,133 @@ export namespace LedgerTransactionMethods { ledgerAccountId: z.number(), }).array(), paymentId: z.number().optional(), - payoutId: z.number().optional(), + manualTransferId: z.number().optional(), }), method: async ({ prisma, session, params }, ) => { + // Calculate the balance for all accounts which are going to be deducted + const debitEntries = params.ledgerEntries.filter(entry => entry.amount < 0) + const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ + params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, + session: null, + }) + + // 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 => entry.amount > balances[entry.ledgerAccountId].amount) + 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, status: 'PENDING', ledgerEntries: { - create: params.ledgerEntries, + create: entries, }, - paymentId: params.paymentId, - payoutId: params.payoutId, + paymentId :params.paymentId, + manualTransferId: params.manualTransferId, }, select: { id: true, }, }) - const transaction = await updateStatus.client(prisma).execute({ + const transaction = await advance.client(prisma).execute({ params: { id, }, session, }) + if (transaction.status === 'FAILED') { + // TODO: Better error message. + throw new ServerError('BAD PARAMETERS', 'Ugyldig transaksjon.') + } + return transaction } }) /** - * Tries to update a given transaction to a state. - * If the transaction is valid and possible (all payments completed, enough balance, etc...) the state is set to succeeded. - * If anything has failed + * Tries to advance the transactions state to a terminal state. + * Also, updates the fees if possible. */ - export const updateStatus = ServiceMethod({ + export const advance = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ id: z.number(), }), method: async ({ session, prisma, params}) => { - const transactionStatus = await determineTransactionStatus(prisma, params.id) + const transaction = await read.client(prisma).execute({ + params: { id: params.id }, + session, + }) + + const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment, transaction.manualTransfer) + + if (creditFees) { + const creditEntries = transaction.ledgerEntries.filter(entry => entry.amount > 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, + }, + }, + select: {}, + }) + + transaction.ledgerEntries.forEach( + entry => entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees + ) + } + + const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ + params: { + ids: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), + atTransactionId: transaction.id, + }, + session: null, + }) + + const transactionStatus = 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, - status: 'PENDING', + status: 'PENDING', // Protect against changing final state. }, data: { status: transactionStatus, + // TODO: Add message detailing why a transaction failed if it did. }, }) - return await read.client(prisma).execute({ - params: { - id: params.id, - }, + return read.client(prisma).execute({ + params: { id: params.id }, session, }) } @@ -297,7 +235,7 @@ export namespace LedgerTransactionMethods { /////////////////////////////////////////////////////////////////////////////////////// - // All ledger entries must have either a ledger account, payment or payout attached. // + // All ledger entries must have either a ledger account, payment or manualTransfer attached. // // Multiple or none are not allowed. (The money must come from/go to somewhere.) // /////////////////////////////////////////////////////////////////////////////////////// @@ -307,7 +245,7 @@ export namespace LedgerTransactionMethods { } const onlySingleAttachments = transaction.ledgerEntries.every( - entry => exactlyOneOf(entry, ['ledgerAccountId', 'paymentId', 'payoutId']) + entry => exactlyOneOf(entry, ['ledgerAccountId', 'paymentId', 'manualTransferId']) ) if (!onlySingleAttachments) { @@ -457,13 +395,13 @@ async function createDeposit(...) { // }) // } -// async function createPayout() { +// async function createmanualTransfer() { // await prisma.$transaction(async (tx) => { // return await transactios.create({ // toAccountId: null, // fromAccountId: ..., // totalAmount: ..., -// purpose: 'PAYOUT', +// purpose: 'manualTransfer', // }) // }) // } diff --git a/src/services/ledger/manualTransfers/methods.ts b/src/services/ledger/manualTransfers/methods.ts new file mode 100644 index 000000000..738c1e19a --- /dev/null +++ b/src/services/ledger/manualTransfers/methods.ts @@ -0,0 +1,16 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { ServiceMethod } from "@/services/ServiceMethod"; +import { z } from 'zod' + +export namespace ManualTransferMethods { + export const create = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Fix + paramsSchema: z.object({ + amount: z.number().int(), + fees: z.number().int(), + bankAccountNumber: z.string().optional(), + comment: z.string().optional(), + }), + method: ({ prisma, params }) => prisma.manualTransfer.create({ data: params }), + }) +} From 68f5d325a78f40c88cf2fff7d9323b180786de75 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 20 Aug 2025 22:16:53 +0200 Subject: [PATCH 15/62] feat: ledger operations --- .../ledger/ledgerOperations/methods.ts | 83 ++++++++++++ src/services/ledger/payments/methods.ts | 121 ++++++++++++++++++ src/services/ledger/payments/schemas.ts | 0 3 files changed, 204 insertions(+) create mode 100644 src/services/ledger/ledgerOperations/methods.ts delete mode 100644 src/services/ledger/payments/schemas.ts diff --git a/src/services/ledger/ledgerOperations/methods.ts b/src/services/ledger/ledgerOperations/methods.ts new file mode 100644 index 000000000..8b4248ae9 --- /dev/null +++ b/src/services/ledger/ledgerOperations/methods.ts @@ -0,0 +1,83 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing" +import { ServiceMethod } from "@/services/ServiceMethod" +import { LedgerTransactionMethods } from "../ledgerTransactions/methods" +import { PaymentMethods } from "../payments/methods" +import { z } from "zod" +import { ManualTransferMethods } from "../manualTransfers/methods" + +export namespace LedgerOperationMethods { + export const createDeposit = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + opensTransaction: true, + paramsSchema: z.object({ + amount: z.number().positive(), + ledgerAccountId: z.number(), + }), + method: async ({ prisma, session, params }) => { + const [payment, transaction] = await prisma.$transaction(async tx => { + const payment = await PaymentMethods.create.client(tx).execute({ + params: { + ...params, + description: 'Innskudd', + descriptor: 'Innskudd', + provider: 'STRIPE', + }, + session, + }) + + const transaction = await LedgerTransactionMethods.create.client(tx).execute({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [params], + paymentId: payment.id, + }, + session, + }) + + return [payment, transaction] + }) + + transaction.payment = await PaymentMethods.initiate.client(prisma).execute({ + params: { paymentId: payment.id }, + session, + }) + + return transaction + } + }) + + export const createPayout = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + paramsSchema: z.object({ + amount: z.number().positive(), + fees: z.number().positive(), + ledgerAccountId: z.number(), + }), + opensTransaction: true, + method: async ({ prisma, params, session }) => { + return prisma.$transaction(async tx => { + const manualTransfer = await ManualTransferMethods.create.client(tx).execute({ + params: { + amount: -params.amount, + fees: -params.fees, + }, + session, + }) + + const transaction = await LedgerTransactionMethods.create.client(tx).execute({ + params: { + purpose: 'PAYOUT', + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + amount: -params.amount, + }], + manualTransferId: manualTransfer.id, + }, + session, + }) + + return transaction + }) + } + }) +} diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts index 4ae7be7e4..3b87d4aa6 100644 --- a/src/services/ledger/payments/methods.ts +++ b/src/services/ledger/payments/methods.ts @@ -105,4 +105,125 @@ export namespace PaymentMethods { } }, }) + + // // TODO: Find a clean way to reuse logic from ledger account! + + // /** + // * Calculates the balance and fees of a payment. Optionally takes a transaction ID to calculate the balance up until that transaction. + // * + // * @warning Non-existent payments will be treated as having a balance of zero. + // * + // * @param params.ids The IDs of the payments to calculate the balance for. + // * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + // * + // * @returns The balances of the payments. + // */ + // export const calculateBalances = ServiceMethod({ + // auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + // paramsSchema: z.object({ + // ids: z.number().array(), + // atTransactionId: z.number().optional(), + // }), + // method: async ({ prisma, params }) => { + // const balanceArray = await prisma.ledgerEntry.groupBy({ + // by: ['paymentId'], + // where: { + // // Select which payments we want to calculate the balance for + // paymentId: { + // not: null, + // 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, + // }, + // // 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: { status: { in: ['PENDING', 'SUCCEEDED'] } }, + // }, + // // Select what fields we should sum + // _sum: { + // amount: true, + // fees: true, + // }, + // }) + + // const amounts = await prisma.payment.findMany({ + // where: { + // id: { + // in: params.ids, + // }, + // }, + // select: { + // amount: true, + // fees: true, + // }, + // }) + + // // The output from the Prisma `groupBy` method is an array. + // // We convert it to an object (record) as it is more sensible for lookups. + // const balanceRecord = Object.fromEntries( + // // The "as const"s are required so that TS understands that the arrays + // // have a length of exactly two. + // [ + // // Set all ids to zero by default in case some payments do not have + // // any ledger entries yet. + // ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), + // // Map the array returned by `groupBy` to key value pairs. + // ...balanceArray.map(balance => [ + // // The "!" is required because the typing for `fromEntries` doesn't accept + // // null as a key. (Even though all keys just get converted to strings during + // // runtime.) The query above guarantees that the id can never be null so + // // its safe anyhow. + // balance.paymentId!, + // { + // // Prisma sets sum to "null" in case the only rows which exist are null. + // // For our case we can treat it as zero. + // amount: balance._sum.amount ?? 0 + balance.amount, + // fees: balance._sum.fees ?? 0 + (balance.fees ?? 0), + // }, + // ] as const), + // ] + // ) + + // // The object returned by `fromEntries` assumes that all keys map to the provided type. + // // This is obviously not true so we need to assert the record as partial. + // return balanceRecord as Partial + // } + // }) + + // /** + // * Calcultates the balance of a single payment. Under the hood it simply uses `calculateBalances`. + // * + // * @warning In case a payment with the provided id doesn't exist a balance of zero will be returned! + // * + // * @param params.id The ID of the payment to calculate the balance for. + // * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. + // * + // * @returns The balances of the payments. + // */ + // export const calculateBalance = ServiceMethod({ + // auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + // paramsSchema: z.object({ + // id: z.number(), + // atTransactionId: z.number().optional(), + // }), + // method: async ({ prisma, session, params }) => { + // const balances = await calculateBalances.client(prisma).execute({ + // params: { + // ids: [params.id], + // atTransactionId: params.atTransactionId, + // }, + // session, + // }) + + // // We know that the returned balances must contain the id we provided. + // // So, we can simply assert that this is not undefined. + // // TODO: There might be a better way to do this? + // return balances[params.id]! + // } + // }) } diff --git a/src/services/ledger/payments/schemas.ts b/src/services/ledger/payments/schemas.ts deleted file mode 100644 index e69de29bb..000000000 From 8d7f0b8230fdd71f5f07a172de00cdf9449b1d6c Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 20 Aug 2025 22:35:01 +0200 Subject: [PATCH 16/62] chore: clean up commented out code --- src/services/ledger/ledgerAccount/Types.ts | 1 + src/services/ledger/ledgerAccount/authers.ts | 1 + src/services/ledger/ledgerAccount/methods.ts | 95 ------- src/services/ledger/ledgerAccount/schemas.ts | 10 - .../ledger/ledgerOperations/methods.ts | 22 ++ .../ledger/ledgerTransactions/methods.ts | 236 ------------------ 6 files changed, 24 insertions(+), 341 deletions(-) diff --git a/src/services/ledger/ledgerAccount/Types.ts b/src/services/ledger/ledgerAccount/Types.ts index f85f75f16..689b1e38b 100644 --- a/src/services/ledger/ledgerAccount/Types.ts +++ b/src/services/ledger/ledgerAccount/Types.ts @@ -6,4 +6,5 @@ export type Balance = { 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/authers.ts b/src/services/ledger/ledgerAccount/authers.ts index e69de29bb..70b786d12 100644 --- a/src/services/ledger/ledgerAccount/authers.ts +++ 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 index a77956327..25162f54b 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -149,36 +149,6 @@ export namespace LedgerAccountMethods { ) return balanceRecord - - // // The output from the Prisma `groupBy` method is an array. - // // We convert it to an object (record) as it is more sensible for lookups. - // const balanceRecord = Object.fromEntries( - // // The "as const"s are required so that TS understands that the arrays - // // have a length of exactly two. - // [ - // // Set all ids to zero by default in case some ledger accounts do not have - // // any ledger entries yet. - // ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), - // // Map the array returned by `groupBy` to key value pairs. - // ...balanceArray.map(balance => [ - // // The "!" is required because the typing for `fromEntries` doesn't accept - // // null as a key. (Even though all keys just get converted to strings during - // // runtime.) The query above guarantees that the id can never be null so - // // its safe anyhow. - // balance.ledgerAccountId!, - // { - // // Prisma sets sum to "null" in case the only rows which exist are null. - // // For our case we can treat it as zero. - // amount: balance._sum.amount ?? 0, - // fees: balance._sum.fees ?? 0, - // }, - // ] as const), - // ] - // ) - - // The object returned by `fromEntries` assumes that all keys map to the provided type. - // This is obviously not true so we need to assert the record as partial. - // return balanceRecord as Partial } }) @@ -208,71 +178,6 @@ export namespace LedgerAccountMethods { }) return balances[params.id] - - // We know that the returned balances must contain the id we provided. - // So, we can simply assert that this is not undefined. - // TODO: There might be a better way to do this? - // return balances[params.id]! } }) - - // const sumPayments = async () => { - // const payments = await prisma.payment.aggregate({ - // where: { - // transaction: { - // id: params.atTransactionId && { lte: params.atTransactionId }, - // toAccountId: params.id, - // status: 'SUCCEEDED', - // } - // }, - // _sum: { - // amount: true, - // fees: true, - // }, - // }) - - // return { - // amount: payments._sum.amount ?? 0, - // fees: payments._sum.fees ?? 0, - // } - // } - - // const sumTransfers = async (direction: 'TO' | 'FROM', accountId: number) => { - // const transactionFilter = { - // id: params.atTransactionId && { lte: params.atTransactionId }, - // fromAccountId: direction === 'FROM' ? accountId : undefined, - // toAccountId: direction === 'TO' ? accountId : undefined, - // // Reserve transfers that are pending (i.e. don't count them towards the balance), - // // but don't make them available at the destination account - // status: direction === 'TO' ? 'SUCCEEDED' : { in: ['SUCCEEDED', 'PENDING'] }, - // } satisfies Prisma.TransactionWhereInput - - // const transfers = await prisma.transfer.aggregate({ - // where: { - // transaction: transactionFilter, - // }, - // _sum: { - // amount: true, - // fees: true, - // }, - // }) - - // return { - // amount: transfers._sum.amount ?? 0, - // fees: transfers._sum.fees ?? 0, - // } - // } - - // const [payments, transfersIn, transfersOut] = await Promise.all([ - // sumPayments(), - // sumTransfers('TO', params.id), - // sumTransfers('FROM', params.id), - // ]) - - // return { - // amount: payments.amount + transfersIn.amount - transfersOut.amount, - // fees: payments.fees + transfersIn.fees - transfersOut.fees, - // } - // } - // }) } diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/ledgerAccount/schemas.ts index 717ebf4c0..d394216f2 100644 --- a/src/services/ledger/ledgerAccount/schemas.ts +++ b/src/services/ledger/ledgerAccount/schemas.ts @@ -16,16 +16,6 @@ export namespace LedgerAccountSchemas { 'Bruker- eller gruppe-ID må være satt.' ) - // .createValidation({ - // keys: ['userId', 'groupId', 'payoutAccountNumber'], - // transformer: data => data, - // refiner: { - // // Only one of userId and groupId can be set - // fcn: data => (data.userId === undefined) !== (data.groupId === undefined), - // message: 'Bruker- eller gruppe-ID må være satt.', - // }, - // }) - export const update = fields.partial().pick({ payoutAccountNumber: true, }) diff --git a/src/services/ledger/ledgerOperations/methods.ts b/src/services/ledger/ledgerOperations/methods.ts index 8b4248ae9..1948b4bb4 100644 --- a/src/services/ledger/ledgerOperations/methods.ts +++ b/src/services/ledger/ledgerOperations/methods.ts @@ -5,7 +5,20 @@ import { PaymentMethods } from "../payments/methods" import { z } from "zod" import { ManualTransferMethods } from "../manualTransfers/methods" +// `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({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), opensTransaction: true, @@ -46,6 +59,15 @@ export namespace LedgerOperationMethods { } }) + /** + * 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({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index 9573e165b..b290fec17 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -219,239 +219,3 @@ export namespace LedgerTransactionMethods { } }) } - - // MIGHT NOT BE NEEDED - /** - * Cancel a pending transaction. If the payment is processing it will be cancel - */ - // export const cancel = ServiceMethod({ - // auther: () => RequireNothing.staticFields({}).dynamicFields({}), - // method: async () => { - - // } - // }) - -/* - - - /////////////////////////////////////////////////////////////////////////////////////// - // All ledger entries must have either a ledger account, payment or manualTransfer attached. // - // Multiple or none are not allowed. (The money must come from/go to somewhere.) // - /////////////////////////////////////////////////////////////////////////////////////// - - // Utility function for checking that an object has exactly one of the provided keys - function exactlyOneOf(obj: T, keys: (keyof T)[]): boolean { - return keys.filter(key => obj[key] !== null).length === 1 - } - - const onlySingleAttachments = transaction.ledgerEntries.every( - entry => exactlyOneOf(entry, ['ledgerAccountId', 'paymentId', 'manualTransferId']) - ) - - if (!onlySingleAttachments) { - return 'FAILED' - } - -// The output from the Prisma `groupBy` method is an array. - // We convert it to an object (record) as it is more sensible for lookups. - const balanceRecord = Object.fromEntries( - // The "as const"s are required so that TS understands that the arrays - // have a length of exactly two. - [ - // Set all ids to zero by default in case some ledger accounts do not have - // any ledger entries yet. - ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), - // Map the array returned by `groupBy` to key value pairs. - ...balanceArray.map(balance => [ - // The "!" is required because the typing for `fromEntries` doesn't accept - // null as a key. (Even though all keys just get converted to strings during - // runtime.) The query above guarantees that the id can never be null so - // its safe anyhow. - balance.ledgerAccountId!, - { - // Prisma sets sum to "null" in case the only rows which exist are null. - // For our case we can treat it as zero. - amount: balance._sum.amount ?? 0, - fees: balance._sum.fees ?? 0, - }, - ] as const), - ] - ) - -// The object returned by `fromEntries` assumes that all keys map to the provided type. -// This is obviously not true so we need to assert the record as partial. - -async function createPurchase() { - const [transaction, purchase] = await prisma.$transaction(async (tx) => { - // Call stripe api and do internal money transfer - const createTransaction({ - ledgerEntries: [ - { - accountId: sellerAccountId, - amount: productPrice, - }, - { - accountId: buyerAccountId, - amount: -productPrice, - }, - ], - }) - - // Create record of what should be bought - const purchase = purchases.create({ - productsPurchases: [{productId: ..., price: ..., quantity: ...}, ...], - userId: ..., - transactionId: transaction.id, - }) - - return [transaction, purchase] - }) - - return [transaction, purchase] -} - -*/ - -/* - -async function createDeposit(...) { - const paymentId = cuid() - - await prisma.$transaction(async (tx) => { - // Create the transaction - // The entries must always sum to zero - money cannot come from nowhere - await createTransaction({ - client: tx, - purpose: 'DEPOSIT', - entries: [ - { - amount: depositAmount, - accountId, - }, - { - amount: -depositAmount, - paymentId, - create: true, - }, - ], - }) - - return payment - }) - - // Call the Stripe API and return the client secret so the user can complete the transaction - return await initiatePayment({ - // Implicitly global prisma - paymentId: payment.id - }) -} - -*/ - -// POST /purchase - -// async function createPurchase() { -// const transaction = await prisma.$transaction(async (tx) => { -// // Create record of what should be bough20222t -// const purchase = purchases.create({ -// productsPurchases: [{productId: ..., price: ..., quantity: ...}, ...], -// userId: ..., -// }) - -// // Call stripe api and do internal money transfer -// return await transactios.create({ -// totalAmount: ..., -// toAccountId: ..., -// fromAccountId: ..., -// purpose: 'PURCHASE', -// purchaseId: purchase.id, -// }) -// }) -// } - -// async function createEventPayment() { -// await prisma.$transaction(async (tx) => { -// return await transactios.create({ -// totalAmount: ..., -// toAccountId: ..., -// fromAccountId: ..., -// purpose: 'EVENT_PAYMENT', -// eventRegistrations: eventRegistration.id, -// }) -// }) -// } - -// async function createDeposit() { -// const paymentIntent = await stripe.paymentIntents.create({ - -// }) - -// await prisma.$transaction(async (tx) => { -// return await transactios.create({ -// totalAmount: ..., -// toAccountId: ..., -// purpose: 'DEPOSIT', -// }) -// }) -// } - -// async function createmanualTransfer() { -// await prisma.$transaction(async (tx) => { -// return await transactios.create({ -// toAccountId: null, -// fromAccountId: ..., -// totalAmount: ..., -// purpose: 'manualTransfer', -// }) -// }) -// } - -/* -// Checks if the source account has enough balance and that the payment (if present) is successful. - async function validateTransaction(prisma: Prisma.TransactionClient, id: number): Promise { - const transaction = await prisma.ledgerTransaction.findUniqueOrThrow({ - where: { id }, - // Only query the fields we need for validation - select: { - id: true, - fromAccountId: true, - status: true, - payment: { - select: { - status: true, - } - }, - transfer: { - // We only need to know if transfer is present or not - select: {}, - }, - } - }) - - // A failed transaction can never be valid - if (transaction.status === 'FAILED') return false - - // A transaction is not valid until its underlying payment is successful (if it has one) - if (transaction.payment?.status !== 'SUCCEEDED') return false - - // A transaction with a transfer must... - if (transaction.transfer !== null) { - // ...have a fromAccount (the money must come from somewhere) - if (transaction.fromAccountId === null) return false - - const fromAccountBalance = await LedgerAccountMethods.calculateBalance.client(prisma).execute({ - params: { - id: transaction.fromAccountId, - atTransactionId: transaction.id, - }, - bypassAuth: true, - session: null, - }) - - // ...and result in a non-negative balance for the fromAccount - if (fromAccountBalance.amount < 0) return false - } - - return true - } -*/ \ No newline at end of file From 9c86fca0b7dcdaadf9c5a64c2e3f5b00b6f13810 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 20 Aug 2025 22:35:44 +0200 Subject: [PATCH 17/62] feat: add manual transfer to schema (forgot to commit this) --- src/prisma/schema/ledger.prisma | 96 +++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 27 deletions(-) diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index aeed324d6..b62137b6d 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -57,14 +57,14 @@ enum LedgerTransactionStatus { // 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 - status LedgerTransactionStatus - ledgerEntries LedgerEntry[] - payment Payment? @relation(fields: [paymentId], references: [id]) - paymentId Int? @unique - payout Payout? @relation(fields: [payoutId], references: [id]) - payoutId Int? @unique + id Int @id @default(autoincrement()) + purpose LedgerTransactionPurpose + status LedgerTransactionStatus + ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique + manualTransfer ManualTransfer? @relation(fields: [manualTransferId], references: [id]) + manualTransferId Int? @unique // Relevant relations to other tables based un purpose // purchase Purchase @@ -130,50 +130,92 @@ enum PaymentStatus { } model Payment { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // The amount that was requested for this payment // Use to confirm that the correct amount was captured - amount Int + amount Int // The amount of fees this payment incurred - fees Int? + fees Int? // Lifecycle of the payment - status PaymentStatus + status PaymentStatus // Which service we should call and listen to - provider PaymentProvider + provider PaymentProvider // If the payment provider is Stripe, then the stripe // payment model holds the details for the payment intent - paymentIntentId String? @unique + paymentIntentId String? @unique // The key the fronted uses to confirm the payment intent - clientSecret String? @unique + clientSecret String? @unique // The reason for the payment, displayed on the stripe dashboard - description String + description String // The text displayed on the bank statement - descriptor String + descriptor String // The ledger account responsible for creating the payment // May be null if user without account is paying - ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) - ledgerAccountId Int? + ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) + ledgerAccountId Int? // Which ledger entries have used this payment // Useful in case payment goes through, // then user can be credited unused amount // into their account. - ledgerTransaction LedgerTransaction? + ledgerTransaction LedgerTransaction? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -// Bookkeeping for where money has been sent out from the website -model Payout { - id Int @id @default(autoincrement()) +/** + * // Function which users call to add money to their ledger account. + * // Payment is completed on the client side. + * // Required references/keys are returned with the transaction object + * async function createDeposit(ledgerAccountId: number, amount: number, paymentProvider: string) { + * const payment = await createPayment({ amount, paymentProvider, ... }) + * const transaction = await createTransaction({ + * entries: [{ + * ledgerAccountId, + * amount, + * }], + * paymentId: payment.id, + * }) + * return transaction + * } + * // Functions users call to remove money from their account and + * // send it to their real life bank account. + * async function createPayout(ledgerAccountId: number, amount: number, bankAccountNumber: string) { + * const manualTransfer = createmanualTransfer({ + * data: { + * amount, + * fees, + * bankAccountNumber, + * } + * }) + * const transaction = await createLedgerTransaction({ + * ledgerEntries: [{ + * ledgerAccountId, + * amount: -amount, + * }], + * manualTransferId: manualTransfer.id, + * }) + * return transaction + * } + */ + +// Bookkeeping for when funds are transferred to or from the ledger manually by administrators. +model ManualTransfer { + id Int @id @default(autoincrement()) + // Important: The actual amount transferred is amount minus fees! + // Example: Say PhaestCom has earned 50'000.00 Kluengende Muent in + // revenue and 1000.00 Kluengende Muent in fees. Then, the actual + // bank transfer should equate to 49'000.00. + // Note: positive = going into the ledger, negative = going out of the ledger amount Int - fees Int? - // The bank account number is only for our own bookkeeping. - // The money has to be transferred manually by HS! + fees Int + // 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 HS! bankAccountNumber String? comment String? - ledgerTransaction LedgerTransaction? + ledgerTransaction LedgerTransaction? } // // The type of a ledger entry determines how it is validated. From c2a64b78a2d088e7e260861251adaf96e316e312 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 20 Aug 2025 23:58:43 +0200 Subject: [PATCH 18/62] test: beginning of ledger tests --- tests/services/ledger/deposits.test.ts | 7 - tests/services/ledger/ledgerAccounts.test.ts | 15 +- .../ledger/ledgerTransactions.test.ts | 130 ++++++++++++++++++ tests/services/ledger/payments.test.ts | 89 +++++++++++- tests/services/permissions.test.ts | 19 +++ 5 files changed, 249 insertions(+), 11 deletions(-) delete mode 100644 tests/services/ledger/deposits.test.ts create mode 100644 tests/services/ledger/ledgerTransactions.test.ts create mode 100644 tests/services/permissions.test.ts diff --git a/tests/services/ledger/deposits.test.ts b/tests/services/ledger/deposits.test.ts deleted file mode 100644 index 555132bf7..000000000 --- a/tests/services/ledger/deposits.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, test } from '@jest/globals' - -describe('deposits', () => { - test('nothing', () => { - - }) -}) diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts index 10fe70756..e259ed377 100644 --- a/tests/services/ledger/ledgerAccounts.test.ts +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -1,7 +1,18 @@ import { describe, test } from '@jest/globals' -describe('ledgerAccount', () => { - test('nothing', () => { +describe('ledger accounts', () => { + const testEntries = [ + [100_00, [{ amount: 100_00, fees: 10_00 }]], + ] + test('balance', () => { + await prisma.ledgerTransaction.create({ + data: { + ledgerEntries: { + create: [ + ], + }, + }, + }) }) }) diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts new file mode 100644 index 000000000..386f5dbba --- /dev/null +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -0,0 +1,130 @@ +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/methods' +import { ManualTransferMethods } from '@/services/ledger/manualTransfers/methods' +import { UserMethods } from '@/services/users/methods' +import { beforeAll, describe, expect, test } from '@jest/globals' +import { afterEach, beforeEach } from 'node:test' + +const testAccountCount = 10 +const initialBalanceAmount = 100_00 +const initialBalanceFees = 10_00 + +describe('ledger transactions', () => { + let testAccountIds: number[] = [] + + // Set up ledger accounts + beforeAll(async () => { + // TODO: Create utility to create test accounts + for (let i = 0; i < testAccountCount; 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 () => { + Promise.all(testAccountIds.map(async accountId => { + const manualTransfer = await ManualTransferMethods.create({ + params: { + amount: initialBalanceAmount, + fees: initialBalanceFees, + }, + }) + + await LedgerTransactionMethods.create({ + params: { + ledgerEntries: [{ + ledgerAccountId: accountId, + amount: initialBalanceAmount, + }], + purpose: 'DEPOSIT', + manualTransferId: manualTransfer.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((amount, i) => ({ amount, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + expect(transaction).toMatchObject({ + status: 'SUCCEEDED', + }) + }) + + 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((amount, i) => ({ amount, ledgerAccountId: testAccountIds[i] })), + purpose: 'DEPOSIT', + }, + }) + + expect(transactionPromise).rejects.toThrow() + + const balances = await LedgerAccountMethods.calculateBalances({ + params: { ids: testAccountIds }, + }) + + testAccountIds.forEach(accountId => { + const balance = balances[accountId] + + expect(balance.amount).toBe(initialBalanceAmount) + expect(balance.fees).toBe(initialBalanceFees) + }) + }) + }) +}) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts index 1f0eada17..41f283c4c 100644 --- a/tests/services/ledger/payments.test.ts +++ b/tests/services/ledger/payments.test.ts @@ -1,7 +1,92 @@ -import { describe, test } from '@jest/globals' +import { describe, test, expect, jest, beforeEach, beforeAll } from '@jest/globals' + +// 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 prisma from '@/prisma' +import { PaymentProvider } from '@prisma/client' +import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' +import Stripe from 'stripe' + +const TEST_PAYMENT_DEFAULTS = { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'STRIPE', + description: 'Test betaling', + descriptor: 'Test betaling', +} describe('payments', () => { - test('nothing', () => { + 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.newClient().execute({ + params: { + ...TEST_PAYMENT_DEFAULTS, + provider, + }, + session: null, + }) + + if (payment.status === 'PENDING') { + payment = await PaymentMethods.initiate.newClient().execute({ + params: { + paymentId: payment.id, + }, + session: null, + }) + + stripeWebhookCallback({ + type: 'payment_intent.succeeded', + data: { + object: { + amount: payment.amount, + latest_charge: { + balance_transaction: { + fee: payment.amount / 100, + }, + }, + }, + }, + } as Stripe.PaymentIntentSucceededEvent) + } + + expect(payment).toMatchObject({ + status: 'SUCCEEDED', + }) + }) + + test('initiate manual payment', async () => { + const payment = await PaymentMethods.create.newClient().execute({ + params: { + ledgerAccountId: 0, + amount: 100, // 1 kr + provider: 'MANUAL', + description: 'Test betaling', + descriptor: 'Test betaling', + }, + session: null, + }) + expect(PaymentMethods.initiate.newClient().execute({ params: { paymentId: payment.id }, session: null })) + .rejects.toThrow(new Smorekopp('BAD DATA')) }) }) diff --git a/tests/services/permissions.test.ts b/tests/services/permissions.test.ts new file mode 100644 index 000000000..771650ba1 --- /dev/null +++ b/tests/services/permissions.test.ts @@ -0,0 +1,19 @@ +import { Session } from '@/auth/Session' +import { Smorekopp } from '@/services/error' +import prisma from '@/prisma' +import { ApiKeyMethods } from '@/services/api-keys/methods' +import { afterEach, beforeAll, describe, expect, test } from '@jest/globals' + +describe('permissions', () => { + test('default', async () => { + // TODO + }) + + test('group', async () => { + // TODO + }) + + test('group and default', async() => { + + }) +}) From d4e9d015fbe021b2e99872e4b3f126f8ca35bb98 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 14:09:45 +0200 Subject: [PATCH 19/62] feat: add reason for why transaction failed --- src/prisma/schema/ledger.prisma | 20 ++++---- .../determineTransactionState.ts | 47 +++++++++++-------- .../ledger/ledgerTransactions/methods.ts | 20 ++++---- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index b62137b6d..8bc813f8e 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -57,14 +57,16 @@ enum LedgerTransactionStatus { // 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 - status LedgerTransactionStatus - ledgerEntries LedgerEntry[] - payment Payment? @relation(fields: [paymentId], references: [id]) - paymentId Int? @unique - manualTransfer ManualTransfer? @relation(fields: [manualTransferId], references: [id]) - manualTransferId Int? @unique + id Int @id @default(autoincrement()) + purpose LedgerTransactionPurpose + status LedgerTransactionStatus + reason String? // If the transaction failed, this is the reason why. + + ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique + manualTransfer ManualTransfer? @relation(fields: [manualTransferId], references: [id]) + manualTransferId Int? @unique // Relevant relations to other tables based un purpose // purchase Purchase @@ -201,7 +203,7 @@ model Payment { // Bookkeeping for when funds are transferred to or from the ledger manually by administrators. model ManualTransfer { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // Important: The actual amount transferred is amount minus fees! // Example: Say PhaestCom has earned 50'000.00 Kluengende Muent in // revenue and 1000.00 Kluengende Muent in fees. Then, the actual diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts index ed3c6cb21..1c09e381a 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -2,14 +2,21 @@ import { LedgerTransactionStatus, PaymentStatus } from "@prisma/client" import { ExpandedLedgerTransaction } from "./Type" import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" +type LedgerTransactionTransition = { + state: LedgerTransactionStatus, + reason?: string, +} + +type LedgerTransactionRule = (transaction: ExpandedLedgerTransaction, balances: BalanceRecord) => LedgerTransactionTransition | undefined + /** * Determines the state of a given transaction. */ -export async function determineTransactionState(transaction: ExpandedLedgerTransaction, balances: BalanceRecord): Promise { +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 = [ + const rules: LedgerTransactionRule[] = [ noTerminalState, noFailedPayment, amountAndFeesHaveSameSigns, @@ -21,29 +28,29 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans ] for (const rule of rules) { - const state = await rule(transaction, balances) + const state = rule(transaction, balances) if (state) return state } - return 'SUCCEEDED' + return { state: 'SUCCEEDED' } } /** * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) * can never change state. */ -function noTerminalState({ status }: ExpandedLedgerTransaction) { - if (status !== 'PENDING') return status +function noTerminalState({ status }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { + if (status !== 'PENDING') return { state: status } } /** * If any payment has failed, the entire transaction has failed. */ -function noFailedPayment({ payment }: ExpandedLedgerTransaction) { +function noFailedPayment({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { const okStates: PaymentStatus[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] const hasFailedPayment = payment && !okStates.includes(payment.status) - if (hasFailedPayment) return 'FAILED' + if (hasFailedPayment) return { state: 'FAILED', reason: 'Betaling mislyktes.' } } /** @@ -51,7 +58,7 @@ function noFailedPayment({ payment }: ExpandedLedgerTransaction) { * * Mathematically: `amount > 0 <=> fees > 0` and `amount < 0 <=> fees < 0`. */ -function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction) { +function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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) @@ -60,7 +67,7 @@ function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: const validManualTransfer = sameSigns(manualTransfer?.amount, manualTransfer?.fees) const validLedgerEntries = ledgerEntries.every(entry => sameSigns(entry.amount, entry.fees)) - if (!validManualTransfer || !validPayment || !validLedgerEntries) return 'FAILED' + if (!validManualTransfer || !validPayment || !validLedgerEntries) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } } @@ -68,21 +75,21 @@ function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: * 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, manualTransfer }: ExpandedLedgerTransaction) { +function validAmountSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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 ledgerEntriesAmountSum = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0) const paymentAmount = payment?.amount ?? 0 const manualTransferAmount = manualTransfer?.amount ?? 0 - if (ledgerEntriesAmountSum !== paymentAmount + manualTransferAmount) return 'FAILED' + if (ledgerEntriesAmountSum !== paymentAmount + manualTransferAmount) return { state: 'FAILED', reason: 'Ugyldig sum av beløp.' } } /** * If an entry is debit (amount < 0), its referenced account must * have a positive balance after the transaction succeeds. */ -async function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balances: BalanceRecord) { +function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balances: BalanceRecord): LedgerTransactionTransition | undefined { const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.amount < 0).map(entry => entry.ledgerAccountId) const debitBalances = debitLedgerAccountIds.map(id => balances[id]) @@ -92,41 +99,41 @@ async function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, const hasNegativeBalance = debitBalances.some(balance => balance.amount < 0 || balance.fees < 0) - if (hasNegativeBalance) return 'FAILED' + if (hasNegativeBalance) return { state: 'FAILED', reason: 'Ikke nok midler for å utføre transaksjonen.' } } /** * If any payment is pending, the transaction is pending. */ -function paymentComplete({ payment }: ExpandedLedgerTransaction) { +function paymentComplete({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // Since we have checked for failure states above, // we can simply check that the transaction has not succeeded. const hasPendingPayment = payment && payment.status !== 'SUCCEEDED' - if (hasPendingPayment) return 'PENDING' + if (hasPendingPayment) return { state: 'PENDING' } } /** * All fees must be non-null. */ -function noNullFees({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction) { +function noNullFees({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { const hasNullFees = ledgerEntries.some(entry => entry.fees === null) || payment && payment.fees === null || manualTransfer && manualTransfer.fees === null - if (hasNullFees) return 'FAILED' + if (hasNullFees) return { state: 'FAILED', reason: 'Manglende gebyrer.' } } /** * Fees must also follow Kirchhoff's first law. */ -function validFeesSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction) { +function validFeesSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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 ledgerEntriesFeesSum = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) const paymentFees = payment?.fees ?? 0 const manualTransferFees = manualTransfer?.fees ?? 0 - if (ledgerEntriesFeesSum !== paymentFees + manualTransferFees) return 'FAILED' + if (ledgerEntriesFeesSum !== paymentFees + manualTransferFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } } diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index b290fec17..165c58f43 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -100,7 +100,7 @@ export namespace LedgerTransactionMethods { // 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 => entry.amount > balances[entry.ledgerAccountId].amount) + const hasInsufficientBalance = debitEntries.some(entry => (balances[entry.ledgerAccountId]?.amount ?? 0) + entry.amount < 0) if (hasInsufficientBalance) { throw new ServerError('BAD PARAMETERS', 'Konto har for lav balanse for å utføre transaksjonen.') } @@ -126,7 +126,7 @@ export namespace LedgerTransactionMethods { id: true, }, }) - + const transaction = await advance.client(prisma).execute({ params: { id, @@ -136,7 +136,7 @@ export namespace LedgerTransactionMethods { if (transaction.status === 'FAILED') { // TODO: Better error message. - throw new ServerError('BAD PARAMETERS', 'Ugyldig transaksjon.') + throw new ServerError('BAD PARAMETERS', transaction.reason ?? 'Transaksjonen feilet av ukjent årsak.') } return transaction @@ -153,7 +153,7 @@ export namespace LedgerTransactionMethods { id: z.number(), }), method: async ({ session, prisma, params}) => { - const transaction = await read.client(prisma).execute({ + let transaction = await read.client(prisma).execute({ params: { id: params.id }, session, }) @@ -181,7 +181,6 @@ export namespace LedgerTransactionMethods { update: ledgerEntryUpdateInput, }, }, - select: {}, }) transaction.ledgerEntries.forEach( @@ -197,7 +196,7 @@ export namespace LedgerTransactionMethods { session: null, }) - const transactionStatus = await determineTransactionState(transaction, balances) + 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. @@ -206,16 +205,15 @@ export namespace LedgerTransactionMethods { id: params.id, status: 'PENDING', // Protect against changing final state. }, - data: { - status: transactionStatus, - // TODO: Add message detailing why a transaction failed if it did. - }, + data: transition, }) - return read.client(prisma).execute({ + transaction = await read.client(prisma).execute({ params: { id: params.id }, session, }) + + return transaction } }) } From 177b41bfe2f14e08e7339a19e0b911374f1886b7 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 14:16:08 +0200 Subject: [PATCH 20/62] style: always use state in stead of status --- jest.config.ts | 5 ++ src/prisma/schema/ledger.prisma | 64 ++----------------- .../determineTransactionState.ts | 14 ++-- .../ledger/ledgerTransactions/methods.ts | 6 +- src/services/ledger/payments/methods.ts | 8 +-- .../ledger/payments/stripeWebhookCallback.ts | 18 +++--- 6 files changed, 33 insertions(+), 82 deletions(-) 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/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 8bc813f8e..363fded10 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -44,7 +44,7 @@ enum LedgerTransactionPurpose { // All ledger transactions start as pending and become // either failed, succeeded or canceled. No other // transitions are possible. -enum LedgerTransactionStatus { +enum LedgerTransactionState { PENDING FAILED SUCCEEDED @@ -59,7 +59,7 @@ enum LedgerTransactionStatus { model LedgerTransaction { id Int @id @default(autoincrement()) purpose LedgerTransactionPurpose - status LedgerTransactionStatus + state LedgerTransactionState reason String? // If the transaction failed, this is the reason why. ledgerEntries LedgerEntry[] @@ -117,7 +117,7 @@ enum PaymentProvider { // TODO: VIPPS } -enum PaymentStatus { +enum PaymentState { // Payment created, but not external API call made PENDING // Awaiting response from payment provider (webhook) @@ -127,7 +127,7 @@ enum PaymentStatus { // Succeed webhook received with correct amount SUCCEEDED // Cancel webhook confirmation received - NOT that we initiated a cancel - // set the transaction status to canceled for that + // set the transaction state to canceled for that CANCELED } @@ -139,7 +139,7 @@ model Payment { // The amount of fees this payment incurred fees Int? // Lifecycle of the payment - status PaymentStatus + state PaymentState // Which service we should call and listen to provider PaymentProvider // If the payment provider is Stripe, then the stripe @@ -165,42 +165,6 @@ model Payment { updatedAt DateTime @updatedAt } -/** - * // Function which users call to add money to their ledger account. - * // Payment is completed on the client side. - * // Required references/keys are returned with the transaction object - * async function createDeposit(ledgerAccountId: number, amount: number, paymentProvider: string) { - * const payment = await createPayment({ amount, paymentProvider, ... }) - * const transaction = await createTransaction({ - * entries: [{ - * ledgerAccountId, - * amount, - * }], - * paymentId: payment.id, - * }) - * return transaction - * } - * // Functions users call to remove money from their account and - * // send it to their real life bank account. - * async function createPayout(ledgerAccountId: number, amount: number, bankAccountNumber: string) { - * const manualTransfer = createmanualTransfer({ - * data: { - * amount, - * fees, - * bankAccountNumber, - * } - * }) - * const transaction = await createLedgerTransaction({ - * ledgerEntries: [{ - * ledgerAccountId, - * amount: -amount, - * }], - * manualTransferId: manualTransfer.id, - * }) - * return transaction - * } - */ - // Bookkeeping for when funds are transferred to or from the ledger manually by administrators. model ManualTransfer { id Int @id @default(autoincrement()) @@ -219,21 +183,3 @@ model ManualTransfer { ledgerTransaction LedgerTransaction? } - -// // The type of a ledger entry determines how it is validated. -// // NOTE: In the context of ledger "credit" means to receive money and "debit" means to loose money -// enum LedgerEntryType { -// // The entry must reference a account -// // No other special requirements -// INTERNAL_CREDIT -// // The entry must reference a account and that account -// // must have a non-negative balance after the transaction. -// INTERNAL_DEBIT -// // Represents money coming into the system. -// // The entry must reference a succeed payment with -// // enough value to cover the amount. -// EXTERNAL_DEBIT -// // Represents money going out of the system. -// // No special requirements. -// EXTERNAL_CREDIT -// } diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts index 1c09e381a..b9a8754cd 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -1,9 +1,9 @@ -import { LedgerTransactionStatus, PaymentStatus } from "@prisma/client" +import { LedgerTransactionState, PaymentState } from "@prisma/client" import { ExpandedLedgerTransaction } from "./Type" import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" type LedgerTransactionTransition = { - state: LedgerTransactionStatus, + state: LedgerTransactionState, reason?: string, } @@ -39,16 +39,16 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) * can never change state. */ -function noTerminalState({ status }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { - if (status !== 'PENDING') return { state: status } +function noTerminalState({ state }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { + if (state !== 'PENDING') return { state } } /** * If any payment has failed, the entire transaction has failed. */ function noFailedPayment({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { - const okStates: PaymentStatus[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] - const hasFailedPayment = payment && !okStates.includes(payment.status) + const okStates: PaymentState[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] + const hasFailedPayment = payment && !okStates.includes(payment.state) if (hasFailedPayment) return { state: 'FAILED', reason: 'Betaling mislyktes.' } } @@ -108,7 +108,7 @@ function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balanc function paymentComplete({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // Since we have checked for failure states above, // we can simply check that the transaction has not succeeded. - const hasPendingPayment = payment && payment.status !== 'SUCCEEDED' + const hasPendingPayment = payment && payment.state !== 'SUCCEEDED' if (hasPendingPayment) return { state: 'PENDING' } } diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index 165c58f43..cd1d08e6b 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -115,7 +115,7 @@ export namespace LedgerTransactionMethods { const { id } = await prisma.ledgerTransaction.create({ data: { purpose: params.purpose, - status: 'PENDING', + state: 'PENDING', ledgerEntries: { create: entries, }, @@ -134,7 +134,7 @@ export namespace LedgerTransactionMethods { session, }) - if (transaction.status === 'FAILED') { + if (transaction.state === 'FAILED') { // TODO: Better error message. throw new ServerError('BAD PARAMETERS', transaction.reason ?? 'Transaksjonen feilet av ukjent årsak.') } @@ -203,7 +203,7 @@ export namespace LedgerTransactionMethods { await prisma.ledgerTransaction.updateMany({ where: { id: params.id, - status: 'PENDING', // Protect against changing final state. + state: 'PENDING', // Protect against changing final state. }, data: transition, }) diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts index 3b87d4aa6..8d465fa13 100644 --- a/src/services/ledger/payments/methods.ts +++ b/src/services/ledger/payments/methods.ts @@ -26,7 +26,7 @@ export namespace PaymentMethods { data: { ...params, // Manual payments are automatically succeeded - status: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', + state: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', } }) }, @@ -53,13 +53,13 @@ export namespace PaymentMethods { select: { amount: true, provider: true, - status: true, + state: true, description: true, descriptor: true, }, }) - if (payment.status !== 'PENDING') { + if (payment.state !== 'PENDING') { throw new ServerError('BAD PARAMETERS', 'Betalingen har allerede blitt forespurt.') } @@ -96,7 +96,7 @@ export namespace PaymentMethods { }, data: { paymentIntentId: paymentIntent.id, - status: 'PROCESSING', + state: 'PROCESSING', }, }) diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts index b89eda9df..31c670f78 100644 --- a/src/services/ledger/payments/stripeWebhookCallback.ts +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -1,7 +1,7 @@ import type Stripe from "stripe" import prisma from "@/prisma" import logger from "@/lib/logger" -import { PaymentStatus } from "@prisma/client" +import { PaymentState } from "@prisma/client" import { stripe } from "@/lib/stripe" /** @@ -29,8 +29,8 @@ function extractBalanceTransaction(paymentIntent: Stripe.PaymentIntent): Stripe. return balanceTransaction } -// Map between Stripe event types and our internal payment statuses. -const EVENT_TYPE_TO_STATUS: Partial> = { +// 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', @@ -54,9 +54,9 @@ const EVENT_TYPE_TO_STATUS: Partial> * @returns An appropriate `Response`. */ export async function stripeWebhookCallback(event: Stripe.Event): Promise { - const paymentStatus = EVENT_TYPE_TO_STATUS[event.type]; + const paymentState = EVENT_TYPE_TO_STATE[event.type]; - if (!paymentStatus) { + if (!paymentState) { logger.error('Received unsupported Stripe event type.') return new Response('Unsupported Stripe event type', { status: 400 }) } @@ -85,15 +85,15 @@ export async function stripeWebhookCallback(event: Stripe.Event): Promise Date: Thu, 21 Aug 2025 14:16:30 +0200 Subject: [PATCH 21/62] fix: set default balance to zero --- src/services/ledger/ledgerAccount/methods.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 25162f54b..dadb45fef 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -73,9 +73,7 @@ export namespace LedgerAccountMethods { }, }) - if (account) { - return account - } + if (account) return account return create.client(prisma).execute({ session, data: params }) }, @@ -117,7 +115,7 @@ export namespace LedgerAccountMethods { // If the amount is greater than zero the entry is a credit (i.e. giving money). amount: { gt: 0 }, // The receiver should (logically) only receive the money if the transaction succeeded. - ledgerTransaction: { status: 'SUCCEEDED' }, + ledgerTransaction: { state: 'SUCCEEDED' }, }, { // If the amount is less than zero the entry is a debit (i.e. taking money). @@ -125,7 +123,7 @@ export namespace LedgerAccountMethods { // 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: { status: { in: ['PENDING', 'SUCCEEDED'] } }, + ledgerTransaction: { state: { in: ['PENDING', 'SUCCEEDED'] } }, }, ], }, @@ -138,15 +136,17 @@ export namespace LedgerAccountMethods { // 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. - const balanceRecord = Object.fromEntries( - balanceArray.map(balance => [ + // 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.amount ?? 0, fees: balance._sum.fees ?? 0 } ]) - ) + ]) return balanceRecord } From 736eca2549228c8a04348aeed0265a3dbc299c60 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 15:03:45 +0200 Subject: [PATCH 22/62] test: calculate fees test --- .../ledgerTransactions/calculateFees.ts | 21 ++++----- tests/services/ledger/calculateFees.test.ts | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) create mode 100644 tests/services/ledger/calculateFees.test.ts diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts index bb6368073..20b423914 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -9,18 +9,17 @@ import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" * should also be 25% of the total fees, i.e., 5 Kl.M. */ export function feesFormula(entryAmount: number, totalAmount: number, totalFees: number) { - let fees = Math.floor(totalFees * entryAmount / totalAmount) - - // Guard against NaN - fees ||= 0 - // Ensure fees are never positive - // (Taking money from an account should never increase that account's fees) - fees = Math.min(fees, 0) - // Ensure fees never exceed the account's fee balance - // (We cannot take more fees than an account has) - fees = Math.max(fees, -totalFees) + if (entryAmount === 0 || totalAmount === 0) return 0; + + let fees = Math.trunc(totalFees * entryAmount / totalAmount); - return fees + // Clamp fees to have same sign as amount + // and never exceed total fees. + if (entryAmount > 0) { + return Math.min(Math.max(fees, 0), totalFees); + } else { + return Math.min(Math.max(fees, -totalFees), 0); + } } /** diff --git a/tests/services/ledger/calculateFees.test.ts b/tests/services/ledger/calculateFees.test.ts new file mode 100644 index 000000000..fa88fbcc0 --- /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) + }) +}) \ No newline at end of file From 173ff8f8b893092550cdf05087dd15afc7c6f986 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:15:35 +0200 Subject: [PATCH 23/62] fix: calculate fees logic --- src/services/ledger/ledgerTransactions/calculateFees.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts index 20b423914..a34c79993 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -58,13 +58,12 @@ export function calculateCreditFees( values.reduce((total, value) => total + (value ?? 0), 0) let totalAmount = sum( - ...ledgerEntries.map(entry => entry.amount), + ...debitLedgerEntries.map(entry => -entry.amount), payment?.amount, manualTransfer?.amount, ) let totalFees = sum( - // Only debit ledger entries may have fees - ...debitLedgerEntries.map(entry => entry.fees), + ...debitLedgerEntries.map(entry => -(entry.fees ?? 0)), payment?.fees, manualTransfer?.fees, ) From 308efd6c40b50a8eb56c3df1f7682b46830ef3f4 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:15:45 +0200 Subject: [PATCH 24/62] fix: disable mail sending during testing --- src/services/notifications/email/mailHandler.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/services/notifications/email/mailHandler.ts b/src/services/notifications/email/mailHandler.ts index f064c20ce..2720ee704 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,10 @@ class MailHandler { } async handleNewMail() { + if (isTest) { + + } + const transporter = await this.getTransporter() const responsePromises = [] @@ -104,7 +109,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 +120,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 => ({ From 841cf9c45ed9cac0bb98119c95b60531483041a3 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:15:56 +0200 Subject: [PATCH 25/62] fix: disable email rendering during testing --- tests/setup.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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), From 3d3dc3efff32f689bff6a253261427d5031b9170 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:16:18 +0200 Subject: [PATCH 26/62] feat: promis util for tests --- tests/utils.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/utils.ts diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 000000000..be46fb4b3 --- /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) +} \ No newline at end of file From 16c0f53116765dd8d7b12059dcffe6e711144b51 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:24:09 +0200 Subject: [PATCH 27/62] fiix: actually disable mail sending during testing --- src/services/notifications/email/mailHandler.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/notifications/email/mailHandler.ts b/src/services/notifications/email/mailHandler.ts index 2720ee704..324b7b044 100644 --- a/src/services/notifications/email/mailHandler.ts +++ b/src/services/notifications/email/mailHandler.ts @@ -88,9 +88,7 @@ class MailHandler { } async handleNewMail() { - if (isTest) { - - } + if (isTest) return const transporter = await this.getTransporter() From ec99d012e60589a0e1f36cabc5bd406d143fbffd Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:49:04 +0200 Subject: [PATCH 28/62] context: tests --- tests/services/context.test.ts | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/services/context.test.ts diff --git a/tests/services/context.test.ts b/tests/services/context.test.ts new file mode 100644 index 000000000..3fd8de32b --- /dev/null +++ b/tests/services/context.test.ts @@ -0,0 +1,49 @@ +import { RequireNothing } from "@/auth/auther/RequireNothing"; +import { Session } from "@/auth/Session"; +import { Context, ServiceMethod } from "@/services/ServiceMethod"; +import { describe, test, expect } from "@jest/globals"; + +const returnContextInfo = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async ({ prisma, session }) => { + return { + inTransaction: "$transaction" in prisma, + apiKeyId: session.apiKeyId, + } + } +}) + +const callReturnContextInfo = ServiceMethod({ + auther: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async () => { + return returnContextInfo({}) + } +}) + +describe('context', () => { + const apiKeySession = Session.fromJsObject({ + apiKeyId: 0, + user: null, + memberships: [], + permissions: [], + }) + const emptySession = Session.empty() + + const contexts: Context[] = [ + { session: emptySession, prisma }, + { session: apiKeySession, prisma }, + { session: null, prisma }, + ] + + 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) + } + }) +}) \ No newline at end of file From dd8d8a8ef0e3e83d9989c5dee8ea950ac7d18da6 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 17:49:39 +0200 Subject: [PATCH 29/62] test: ledger transaction tests --- tests/services/ledger/ledgerAccounts.test.ts | 11 +-- .../ledger/ledgerTransactions.test.ts | 70 +++++++++++-------- tests/services/ledger/payments.test.ts | 22 +++--- 3 files changed, 52 insertions(+), 51 deletions(-) diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts index e259ed377..537492497 100644 --- a/tests/services/ledger/ledgerAccounts.test.ts +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -4,15 +4,6 @@ describe('ledger accounts', () => { const testEntries = [ [100_00, [{ amount: 100_00, fees: 10_00 }]], ] - test('balance', () => { - await prisma.ledgerTransaction.create({ - data: { - ledgerEntries: { - create: [ - - ], - }, - }, - }) + test('balance', async () => { }) }) diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index 386f5dbba..f0ec66060 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -2,12 +2,11 @@ import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/methods' import { ManualTransferMethods } from '@/services/ledger/manualTransfers/methods' import { UserMethods } from '@/services/users/methods' -import { beforeAll, describe, expect, test } from '@jest/globals' -import { afterEach, beforeEach } from 'node:test' +import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' +import { allSettledOrThrow } from 'tests/utils' -const testAccountCount = 10 -const initialBalanceAmount = 100_00 -const initialBalanceFees = 10_00 +const TEST_ACCOUNT_COUNT = 3 +const INITIAL_BALANCE = { amount: 100_00, fees: 10_00 } describe('ledger transactions', () => { let testAccountIds: number[] = [] @@ -15,7 +14,7 @@ describe('ledger transactions', () => { // Set up ledger accounts beforeAll(async () => { // TODO: Create utility to create test accounts - for (let i = 0; i < testAccountCount; i++) { + await allSettledOrThrow(Array.from({ length: TEST_ACCOUNT_COUNT }).map(async (_, i) => { const username = `testuser${i + 1}` const testUser = await UserMethods.create({ @@ -36,7 +35,7 @@ describe('ledger transactions', () => { }) testAccountIds.push(testAccount.id) - } + })) }) afterEach(async () => { @@ -50,25 +49,26 @@ describe('ledger transactions', () => { describe('internal transactions', () => { beforeEach(async () => { - Promise.all(testAccountIds.map(async accountId => { - const manualTransfer = await ManualTransferMethods.create({ - params: { - amount: initialBalanceAmount, - fees: initialBalanceFees, - }, + await allSettledOrThrow(testAccountIds.map(async accountId => { + const manualTransfer = await ManualTransferMethods.create({ + params: { + amount: INITIAL_BALANCE.amount, + fees: INITIAL_BALANCE.fees, + }, + }) + + await LedgerTransactionMethods.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: accountId, + amount: INITIAL_BALANCE.amount, + }], + manualTransferId: manualTransfer.id, + } + }) }) - - await LedgerTransactionMethods.create({ - params: { - ledgerEntries: [{ - ledgerAccountId: accountId, - amount: initialBalanceAmount, - }], - purpose: 'DEPOSIT', - manualTransferId: manualTransfer.id, - } - }) - })) + ) }) const validLedgerEntries: number[][] = [ @@ -91,10 +91,21 @@ describe('ledger transactions', () => { }) expect(transaction).toMatchObject({ - status: 'SUCCEEDED', + 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], @@ -113,7 +124,7 @@ describe('ledger transactions', () => { }, }) - expect(transactionPromise).rejects.toThrow() + await expect(transactionPromise).rejects.toThrow() const balances = await LedgerAccountMethods.calculateBalances({ params: { ids: testAccountIds }, @@ -122,8 +133,7 @@ describe('ledger transactions', () => { testAccountIds.forEach(accountId => { const balance = balances[accountId] - expect(balance.amount).toBe(initialBalanceAmount) - expect(balance.fees).toBe(initialBalanceFees) + expect(balance.amount).toBe(INITIAL_BALANCE.amount) }) }) }) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts index 41f283c4c..e9a7111cc 100644 --- a/tests/services/ledger/payments.test.ts +++ b/tests/services/ledger/payments.test.ts @@ -1,14 +1,14 @@ import { describe, test, expect, jest, beforeEach, beforeAll } from '@jest/globals' // TODO: -jest.mock('@/lib/stripe', () => ({ - stripe: { - paymentIntent: { - create: jest.fn(), - cancel: jest.fn(), - }, - }, -})) +// jest.mock('@/lib/stripe', () => ({ +// stripe: { +// paymentIntent: { +// create: jest.fn(), +// cancel: jest.fn(), +// }, +// }, +// })) import { Smorekopp } from '@/services/error' import { PaymentMethods } from '@/services/ledger/payments/methods' @@ -25,7 +25,7 @@ const TEST_PAYMENT_DEFAULTS = { descriptor: 'Test betaling', } -describe('payments', () => { +describe.skip('payments', () => { beforeAll(async () => { await prisma.ledgerAccount.createMany({ data: Array(2).fill({ type: 'USER' }), @@ -46,7 +46,7 @@ describe('payments', () => { session: null, }) - if (payment.status === 'PENDING') { + if (payment.state === 'PENDING') { payment = await PaymentMethods.initiate.newClient().execute({ params: { paymentId: payment.id, @@ -70,7 +70,7 @@ describe('payments', () => { } expect(payment).toMatchObject({ - status: 'SUCCEEDED', + state: 'SUCCEEDED', }) }) From d0c57e4e3b70e7a19710ae5a4cea8bff64879508 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Thu, 21 Aug 2025 20:12:35 +0200 Subject: [PATCH 30/62] fix: package-lock.json after rebase --- package-lock.json | 207 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 155 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 222e8a451..6a982c676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,16 +162,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", - "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "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.26.5", - "@babel/types": "^7.26.5", - "@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": { @@ -423,13 +423,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "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.26.7" + "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" @@ -746,43 +746,43 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "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.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", - "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "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.26.2", - "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@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/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "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": { @@ -846,6 +846,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "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": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", @@ -2141,12 +2151,13 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.3.tgz", - "integrity": "sha512-s3Q/NOorCsLYdCKvQlWU+a+GeAd3C8Rb3L1YnetsgwXzhc3UTWrtQpB/3eCjFOdGUj5QmXfRak12uocd1ZiiQw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.1.tgz", + "integrity": "sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2156,12 +2167,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.3.tgz", - "integrity": "sha512-Zxl/TwyXVZPCFSf0u2BNj5sE0F2uR6iSKxWpq4Wlk/Sv9Ob6YCKByQTkV2y6BCic+fkabp9190hyrDdPA/dNrw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.1.tgz", + "integrity": "sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2171,12 +2183,13 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.3.tgz", - "integrity": "sha512-T5+gg2EwpsY3OoaLxUIofmMb7ohAUlcNZW0fPQ6YAutaWJaxt1Z1h+8zdl4FRIOr5ABAAhXtBcpkZNwUcKI2fw==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.1.tgz", + "integrity": "sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2186,12 +2199,13 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.3.tgz", - "integrity": "sha512-WkAk6R60mwDjH4lG/JBpb2xHl2/0Vj0ZRu1TIzWuOYfQ9tt9NFsIinI1Epma77JVgy81F32X/AeD+B2cBu/YQA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.1.tgz", + "integrity": "sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -2233,12 +2247,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.3.tgz", - "integrity": "sha512-9TEp47AAd/ms9fPNgtgnT7F3M1Hf7koIYYWCMQ9neOwjbVWJsHZxrFbI3iEDJ8rf1TDGpmHbKxXf2IFpAvheIQ==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.1.tgz", + "integrity": "sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -2248,12 +2263,13 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.3.tgz", - "integrity": "sha512-VNAz+HN4OGgvZs6MOoVfnn41kBzT+M+tB+OK4cww6DNyWS6wKaDpaAm/qLeOUbnMh0oVx1+mg0uoYARF69dJyA==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.1.tgz", + "integrity": "sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -5063,7 +5079,7 @@ "version": "0.25.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -8493,9 +8509,7 @@ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true + "license": "ISC" }, "node_modules/make-event-props": { "version": "1.6.2", @@ -11764,6 +11778,95 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "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", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.2", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@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": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "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": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", From 3e1de12d16a4700e3ba4a78a5824b44e4ff9ed12 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sat, 23 Aug 2025 01:11:19 +0200 Subject: [PATCH 31/62] feat: add ui from my other computer --- src/actions/ledger/ledgerAccount.ts | 8 -- src/actions/ledger/transactions/deposits.ts | 6 -- src/actions/ledger/transactions/payments.ts | 6 -- src/actions/ledger/transactions/payouts.ts | 6 -- .../ledger/transactions/transactions.ts | 6 -- .../Ledger/LedgerAccountBalance.module.scss | 14 ++++ .../Ledger/LedgerAccountBalance.tsx | 9 ++- .../Ledger/TransactionList.module.scss | 10 +++ .../_components/Ledger/TransactionList.tsx | 45 +++++++++++ .../Ledger/TransactionRow.module.scss | 12 +++ src/app/_components/Ledger/TransactionRow.tsx | 20 +++++ src/app/_components/PopUp/PopUp.module.scss | 1 + src/app/_components/PopUp/PopUp.tsx | 28 ++++--- src/app/_components/Stripe/PaymentForm.tsx | 4 +- src/app/admin/accounts/page.tsx | 3 + .../(user-admin)/account/BankCardModal.tsx | 20 +++++ .../(user-admin)/account/Card.module.scss | 18 +++++ .../[username]/(user-admin)/account/Card.tsx | 14 ++++ .../(user-admin)/account/DepositModal.tsx | 38 ++++++++++ .../(user-admin)/account/PayoutModal.tsx | 38 ++++++++++ .../(user-admin)/account/page.module.scss | 10 +++ .../[username]/(user-admin)/account/page.tsx | 76 +++++++++++++------ .../account/transactions/page.tsx | 6 +- .../users/[username]/(user-admin)/layout.tsx | 2 +- src/contexts/paging/TranasctionPaging.tsx | 36 ++++----- 25 files changed, 343 insertions(+), 93 deletions(-) delete mode 100644 src/actions/ledger/ledgerAccount.ts delete mode 100644 src/actions/ledger/transactions/deposits.ts delete mode 100644 src/actions/ledger/transactions/payments.ts delete mode 100644 src/actions/ledger/transactions/payouts.ts delete mode 100644 src/actions/ledger/transactions/transactions.ts create mode 100644 src/app/_components/Ledger/LedgerAccountBalance.module.scss create mode 100644 src/app/_components/Ledger/TransactionList.module.scss create mode 100644 src/app/_components/Ledger/TransactionList.tsx create mode 100644 src/app/_components/Ledger/TransactionRow.module.scss create mode 100644 src/app/_components/Ledger/TransactionRow.tsx create mode 100644 src/app/admin/accounts/page.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/BankCardModal.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/Card.module.scss create mode 100644 src/app/users/[username]/(user-admin)/account/Card.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/DepositModal.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/PayoutModal.tsx create mode 100644 src/app/users/[username]/(user-admin)/account/page.module.scss diff --git a/src/actions/ledger/ledgerAccount.ts b/src/actions/ledger/ledgerAccount.ts deleted file mode 100644 index 0ca70440c..000000000 --- a/src/actions/ledger/ledgerAccount.ts +++ /dev/null @@ -1,8 +0,0 @@ -'use server' - -import { action } from '@/actions/action' -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' - -export const createLedgerAccount = action(LedgerAccountMethods.create) -export const readLedgerAccount = action(LedgerAccountMethods.read) -export const calculateLedgerAccountBalance = action(LedgerAccountMethods.calculateBalance) diff --git a/src/actions/ledger/transactions/deposits.ts b/src/actions/ledger/transactions/deposits.ts deleted file mode 100644 index a883ec793..000000000 --- a/src/actions/ledger/transactions/deposits.ts +++ /dev/null @@ -1,6 +0,0 @@ -'use server' - -import { action } from '@/actions/action' -import { DepositMethods } from '@/services/ledger/transactions/deposits/methods' - -export const createStripeDeposit = action(DepositMethods.createStripe) diff --git a/src/actions/ledger/transactions/payments.ts b/src/actions/ledger/transactions/payments.ts deleted file mode 100644 index 6f6ab5627..000000000 --- a/src/actions/ledger/transactions/payments.ts +++ /dev/null @@ -1,6 +0,0 @@ -'use server' - -import { action } from '@/actions/action' -import { PaymentMethods } from '@/services/ledger/transactions/payment/methods' - -export const createPayment = action(PaymentMethods.create) diff --git a/src/actions/ledger/transactions/payouts.ts b/src/actions/ledger/transactions/payouts.ts deleted file mode 100644 index 4807ab78a..000000000 --- a/src/actions/ledger/transactions/payouts.ts +++ /dev/null @@ -1,6 +0,0 @@ -'use server' - -import { action } from '@/actions/action' -import { PayoutMethods } from '@/services/ledger/transactions/payouts/methods' - -export const createPayout = action(PayoutMethods.create) diff --git a/src/actions/ledger/transactions/transactions.ts b/src/actions/ledger/transactions/transactions.ts deleted file mode 100644 index 46f6dca7f..000000000 --- a/src/actions/ledger/transactions/transactions.ts +++ /dev/null @@ -1,6 +0,0 @@ -'use server' - -import { action } from '@/actions/action' -import { TransactionMethods } from '@/services/ledger/transactions/methods' - -export const readTransactionsPage = action(TransactionMethods.readPage) diff --git a/src/app/_components/Ledger/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/LedgerAccountBalance.module.scss new file mode 100644 index 000000000..fbebff1b2 --- /dev/null +++ b/src/app/_components/Ledger/LedgerAccountBalance.module.scss @@ -0,0 +1,14 @@ +.LedgerAccountBalance { + // Temporarly empty + + // p { + // display: grid; + // grid-template-columns: auto 1fr; + // // gap: 0.5rem; + // // margin: 0.25rem 0; + + // span { + // min-width: 80px; // forces alignment + // } + // } +} \ No newline at end of file diff --git a/src/app/_components/Ledger/LedgerAccountBalance.tsx b/src/app/_components/Ledger/LedgerAccountBalance.tsx index 171c233c5..9b64ccc0c 100644 --- a/src/app/_components/Ledger/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/LedgerAccountBalance.tsx @@ -1,4 +1,5 @@ -import { calculateLedgerAccountBalance } from '@/actions/ledger/ledgerAccount' +import styles from './LedgerAccountBalance.module.scss' +// import { calculateLedgerAccountBalance } from '@/actions/ledger/ledgerAccount' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { displayAmount } from '@/lib/currency/convert' @@ -8,10 +9,10 @@ type Props = { } export default async function LedgerAccountBalance({ accountId, showFees }: Props) { - const balance = unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) + const balance = { amount: 100, fees: 2 } // unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) - return
-

Balanse: {displayAmount(balance.total)} Kluengende muent

+ return
+

Balanse: {displayAmount(balance.amount)} Kluengende muent

{showFees &&

Avgifter: {displayAmount(balance.fees)} Kluengende muent

}
} diff --git a/src/app/_components/Ledger/TransactionList.module.scss b/src/app/_components/Ledger/TransactionList.module.scss new file mode 100644 index 000000000..427338a37 --- /dev/null +++ b/src/app/_components/Ledger/TransactionList.module.scss @@ -0,0 +1,10 @@ +@use '@/styles/ohma'; + +.DotList { + @include ohma.table(); + margin-top: 0; + .inactive { + text-decoration: line-through; + color: ohma.$colors-red; + } +} \ No newline at end of file diff --git a/src/app/_components/Ledger/TransactionList.tsx b/src/app/_components/Ledger/TransactionList.tsx new file mode 100644 index 000000000..f356de00a --- /dev/null +++ b/src/app/_components/Ledger/TransactionList.tsx @@ -0,0 +1,45 @@ +'use client' + +import styles from './TransactionList.module.scss' +import TransactionRow from './TransactionRow' +import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' + +type Props = { + accountId: number, + // TODO: showFees?: boolean, +} + +export default function TransactionList({ accountId }: Props) { + return + + + + + + + + + + + + + + + + + + + + + {/* The EndlessScroll component will render the rows */} + +
DatoBeløpTypeBeskrivelseBetalingsmåteSaldoendring
01.01.19700 Kluengende MeuntInnskudd-Ingen-
+ + } + /> +
+} diff --git a/src/app/_components/Ledger/TransactionRow.module.scss b/src/app/_components/Ledger/TransactionRow.module.scss new file mode 100644 index 000000000..b5e3075d6 --- /dev/null +++ b/src/app/_components/Ledger/TransactionRow.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/TransactionRow.tsx b/src/app/_components/Ledger/TransactionRow.tsx new file mode 100644 index 000000000..143066246 --- /dev/null +++ b/src/app/_components/Ledger/TransactionRow.tsx @@ -0,0 +1,20 @@ +// import styles from './TransactionRow.module.scss' +import { displayAmount } from '@/lib/currency/convert' +import type { Prisma } from '@prisma/client' + +type Props = { + transaction: Prisma.TransactionGetPayload<{ include: { payment: true, transfer: true } }>, + showFees?: boolean, +} + +export default function TransactionRow({ transaction, showFees }: Props) { + const totalAmount = Number(transaction.transfer?.amount) + Number(transaction.payment?.amount) + const totalFees = Number(transaction.transfer?.fees) + Number(transaction.payment?.fees) + return + {transaction.createdAt.toLocaleString()} + {transaction.purpose} + {displayAmount(totalAmount)} + {/* {showFees &&

{displayAmount(totalFees)}

} */} + {transaction.status} + +} diff --git a/src/app/_components/PopUp/PopUp.module.scss b/src/app/_components/PopUp/PopUp.module.scss index 348f423fa..ca3981bc8 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; diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index f3e8a6514..b58fd9bed 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -11,15 +11,17 @@ 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, @@ -72,13 +74,17 @@ export default function PopUp({ setIsOpen(true) }, []) - return ( - - ) + return <>{ + customShowButton ? ( + customShowButton(handleOpening) + ) : ( + + ) + } } diff --git a/src/app/_components/Stripe/PaymentForm.tsx b/src/app/_components/Stripe/PaymentForm.tsx index 40a411e28..13212834b 100644 --- a/src/app/_components/Stripe/PaymentForm.tsx +++ b/src/app/_components/Stripe/PaymentForm.tsx @@ -1,7 +1,7 @@ 'use client' import Form from '@/components/Form/Form' -import { createStripeDeposit } from '@/actions/ledger/transactions/deposits' +// import { createStripeDeposit } from '@/actions/ledger/transactions/deposits' import { createActionError } from '@/actions/error' import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' import React from 'react' @@ -25,7 +25,7 @@ export default function PaymentForm({ accountId, children }: Props) { return createActionError('BAD DATA', '') } - const deposit = await createStripeDeposit({ accountId }, formData) + const deposit = { success: true, data: {} as any } as const //await createStripeDeposit({ accountId }, formData) if (!deposit.success) { return deposit diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx new file mode 100644 index 000000000..d18019fb5 --- /dev/null +++ b/src/app/admin/accounts/page.tsx @@ -0,0 +1,3 @@ +export default function Accounts() { + return "Yo" +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx new file mode 100644 index 000000000..fa7b4d1d6 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx @@ -0,0 +1,20 @@ +"use client" + +import PopUp from "@/app/_components/PopUp/PopUp"; +import Button from "@/app/_components/UI/Button"; + +type PropTypes = { + userId: number, +} + +export default function BankCardModal({userId }: PropTypes) { + return ( + } + > +

Legg til bankkort

+

TODO

+
+ ) +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/Card.module.scss b/src/app/users/[username]/(user-admin)/account/Card.module.scss new file mode 100644 index 000000000..38fc00b2a --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/Card.module.scss @@ -0,0 +1,18 @@ +@use "@/styles/ohma"; + +$background: ohma.$colors-white; + +.Card { + // min-width: 200px; + // max-width: 300px; + 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); + margin: 5*ohma.$gap 0; + // text-decoration: none; + // display: block; + // height: $height; + overflow: hidden; + background-color: $background; + // margin: calc(2 * ohma.$gap);; +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/Card.tsx b/src/app/users/[username]/(user-admin)/account/Card.tsx new file mode 100644 index 000000000..01a50ebf7 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/Card.tsx @@ -0,0 +1,14 @@ +import styles from './Card.module.scss' +import type { ReactNode } from 'react' + +type PropTypes = { + children?: ReactNode, +} + +export default function Card({ children }: PropTypes) { + return ( +
+ {children} +
+ ) +} diff --git a/src/app/users/[username]/(user-admin)/account/DepositModal.tsx b/src/app/users/[username]/(user-admin)/account/DepositModal.tsx new file mode 100644 index 000000000..550223214 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/DepositModal.tsx @@ -0,0 +1,38 @@ +"use client" + +import Form from "@/app/_components/Form/Form"; +import PopUp from "@/app/_components/PopUp/PopUp"; +import Button from "@/app/_components/UI/Button"; +import Checkbox from "@/app/_components/UI/Checkbox"; +import NumberInput from "@/app/_components/UI/NumberInput"; +import TextInput from "@/app/_components/UI/TextInput"; +import { currencySymbol } from "@/lib/currency/config"; +import { displayAmount } from "@/lib/currency/convert"; + +type PropTypes = { + accountId: number, + paymentAmount?: number, + accountNumber?: string, +} + +export default function DepositModal({ accountId }: PropTypes) { + return ( + } + > +

Sett inn {currencySymbol}

+ {/*
({ success: true, data: { accountId } })} + > */} + + {/* */} +

Betal med...

+ + + +
+ ) +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx b/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx new file mode 100644 index 000000000..d6e5a85d3 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx @@ -0,0 +1,38 @@ +"use client" + +import Form from "@/app/_components/Form/Form"; +import PopUp from "@/app/_components/PopUp/PopUp"; +import Button from "@/app/_components/UI/Button"; +import Checkbox from "@/app/_components/UI/Checkbox"; +import NumberInput from "@/app/_components/UI/NumberInput"; +import TextInput from "@/app/_components/UI/TextInput"; +import { currencySymbol } from "@/lib/currency/config"; +import { displayAmount } from "@/lib/currency/convert"; + +type PropTypes = { + accountId: number, + paymentAmount?: number, + accountNumber?: string, +} + +export default function PayoutModal({ accountId, paymentAmount, accountNumber }: PropTypes) { + return ( + } + > +

Registrer utbetaling

+ {paymentAmount &&

Utestående beløp: {displayAmount(paymentAmount)} {currencySymbol}

} +

Oppgitt kontonummer for utbetaling: {accountNumber ? {accountNumber} : Ingen}

+
({ success: true, data: { accountId } })} + > + + + + +
+ ) +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/page.module.scss b/src/app/users/[username]/(user-admin)/account/page.module.scss new file mode 100644 index 000000000..30ed176d1 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/page.module.scss @@ -0,0 +1,10 @@ +@use '@/styles/ohma'; + +.Wrapper { + border-radius: ohma.$cardRounding; + background: ohma.$colors-white; + box-shadow: 0 3px 6px rgba(0,0,0,.16),0 3px 6px rgba(0,0,0,.23); + border-radius: 1em; + margin-bottom: 2em; + padding-bottom: 2em; +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index c171e943b..522ed11b0 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,16 +1,14 @@ -import { bindParams } from '@/actions/bind' -import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' -import { createPayment } from '@/actions/ledger/transactions/payments' -import { createPayout } from '@/actions/ledger/transactions/payouts' -import Form from '@/app/_components/Form/Form' +// import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' import LedgerAccountBalance from '@/app/_components/Ledger/LedgerAccountBalance' -import DepositForm from '@/app/_components/Stripe/DepositForm' -import NumberInput from '@/app/_components/UI/NumberInput' import TextInput from '@/app/_components/UI/TextInput' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/getUser' import Button from '@/components/UI/Button' import Link from 'next/link' +import PayoutModal from './PayoutModal' +import DepositModal from './DepositModal' +import Card from './Card' +import BankCardModal from './BankCardModal' export default async function Account() { const session = await getUser({ @@ -18,16 +16,27 @@ export default async function Account() { shouldRedirect: true, }) // TODO: Replace - const account = unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) + const account = { id: 1 }; //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) return
-

Konto

- -
-

Innskudd

- -
-

Betaling

+ +

Konto

+ + + +
+ {/* } + > +

Sett inn muenter

+ +
*/} + + {/* */} + {/*

Innskudd

+
*/} + {/*

Betaling

-
-

Transaksjoner

-

Se alle transaksjoner ->

-
-

Betalingsalternativer

- - -

VIPPS (TODO)

+
*/} + +

Betalingsalternativer

+

Bankkort

+

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

+ +

NTNU-kort

+

+ For å benytte Kioleskabet på Lophtet må et NTNU-kort være tilknyttet brukeren din. +

+ Gå til siden for kortregistrering. +
+ +

Transaksjoner

+ + + + + + + + + +
En transaksjon
En annen transaksjon
+

Se alle transaksjoner ->

+
} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index 92032e7fe..a1177dccc 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,4 +1,4 @@ -import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' +// import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' import TransactionList from '@/app/_components/Ledger/TransactionList/TransactionList' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/getUser' @@ -15,7 +15,9 @@ export default async function Transactions() { shouldRedirect: true, }) - const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })) + // const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })) + + 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..de6303598 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/contexts/paging/TranasctionPaging.tsx b/src/contexts/paging/TranasctionPaging.tsx index 4ab2cff77..9cfdf5020 100644 --- a/src/contexts/paging/TranasctionPaging.tsx +++ b/src/contexts/paging/TranasctionPaging.tsx @@ -1,24 +1,24 @@ 'use client' import generatePagingProvider, { generatePagingContext } from './PagingGenerator' -import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' +// import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' import type { ReadPageInput } from '@/lib/paging/Types' -import type { Transaction } from '@prisma/client' +import type { LedgerTransaction } from '@prisma/client' -export type PageSizeTransactions = 10 -const fetcher = async ( - paging: ReadPageInput -) => readTransactionsPage({ paging }) +// export type PageSizeTransactions = 10 +// const fetcher = async ( +// paging: ReadPageInput +// ) => readTransactionsPage({ paging }) -export const TransactionPagingContext = generatePagingContext< - Transaction, - { id: number }, - PageSizeTransactions, - { accountId: number } ->() -const TransactionPagingProvider = generatePagingProvider({ - Context: TransactionPagingContext, - fetcher, - getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), -}) -export default TransactionPagingProvider +// export const TransactionPagingContext = generatePagingContext< +// Transaction, +// { id: number }, +// PageSizeTransactions, +// { accountId: number } +// >() +// const TransactionPagingProvider = generatePagingProvider({ +// Context: TransactionPagingContext, +// fetcher, +// getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), +// }) +// export default TransactionPagingProvider From 86a673c707ebba34cbb29a91e39748d551d72347 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 25 Aug 2025 13:54:25 +0200 Subject: [PATCH 32/62] refactor: improve services to make UI easier --- src/prisma/schema/ledger.prisma | 107 +++++----- src/services/ledger/ledgerAccount/methods.ts | 8 +- .../ledger/ledgerOperations/methods.ts | 22 ++- .../ledger/ledgerTransactions/Type.ts | 1 - .../ledgerTransactions/calculateFees.ts | 34 ++-- .../determineTransactionState.ts | 53 +++-- .../ledger/ledgerTransactions/methods.ts | 16 +- .../ledger/manualTransfers/methods.ts | 16 -- src/services/ledger/payments/methods.ts | 185 +++++------------- .../ledger/payments/stripeWebhookCallback.ts | 24 ++- 10 files changed, 181 insertions(+), 285 deletions(-) delete mode 100644 src/services/ledger/manualTransfers/methods.ts diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 363fded10..3fd141edb 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -23,9 +23,6 @@ model LedgerAccount { payoutAccountNumber String? // For display only, only used for group accounts ledgerEntries LedgerEntry[] - payments Payment[] - - // products Product[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -62,11 +59,9 @@ model LedgerTransaction { 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 - manualTransfer ManualTransfer? @relation(fields: [manualTransferId], references: [id]) - manualTransferId Int? @unique + ledgerEntries LedgerEntry[] + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId Int? @unique // Relevant relations to other tables based un purpose // purchase Purchase @@ -80,16 +75,13 @@ model LedgerTransaction { model LedgerEntry { id Int @id @default(autoincrement()) - // The amount this ledger entry moves + // The funds this ledger entry moves // Credit when > 0, debit when < 0 - amount Int + 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? - // What type of movement this ledger entry does. - // Determines how it is validated. - // type LedgerEntryType // The account which should be credited/debited on completions ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) ledgerAccountId Int @@ -112,9 +104,8 @@ model LedgerEntry { } enum PaymentProvider { - MANUAL // Admin injected money into the ledger STRIPE - // TODO: VIPPS + MANUAL } enum PaymentState { @@ -124,62 +115,74 @@ enum PaymentState { PROCESSING // Failed webhook received :( FAILED - // Succeed webhook received with correct amount + // 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 amount that was requested for this payment - // Use to confirm that the correct amount was captured - amount Int - // The amount of fees this payment incurred - fees Int? - // Lifecycle of the payment - state PaymentState - // Which service we should call and listen to - provider PaymentProvider - // If the payment provider is Stripe, then the stripe - // payment model holds the details for the payment intent - paymentIntentId String? @unique - // The key the fronted uses to confirm the payment intent - clientSecret String? @unique + 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 - description String + descriptionLong String? // The text displayed on the bank statement - descriptor String - // The ledger account responsible for creating the payment - // May be null if user without account is paying - ledgerAccount LedgerAccount? @relation(fields: [ledgerAccountId], references: [id]) - ledgerAccountId Int? + 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 { + paymentIntentId String? @unique + // The key the fronted uses to confirm the payment intent + clientSecret String? @unique + + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @unique + // Which ledger entries have used this payment // Useful in case payment goes through, - // then user can be credited unused amount + // then user can be credited unused funds // into their account. - ledgerTransaction LedgerTransaction? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // Bookkeeping for when funds are transferred to or from the ledger manually by administrators. -model ManualTransfer { - id Int @id @default(autoincrement()) - // Important: The actual amount transferred is amount minus fees! - // Example: Say PhaestCom has earned 50'000.00 Kluengende Muent in - // revenue and 1000.00 Kluengende Muent in fees. Then, the actual - // bank transfer should equate to 49'000.00. - // Note: positive = going into the ledger, negative = going out of the ledger - amount Int - fees Int +// 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 { // 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 HS! + // 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? - comment String? - ledgerTransaction LedgerTransaction? + payment Payment @relation(fields: [paymentId], references: [id]) + paymentId Int @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index dadb45fef..08424dcc2 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -113,13 +113,13 @@ export namespace LedgerAccountMethods { OR: [ { // If the amount is greater than zero the entry is a credit (i.e. giving money). - amount: { gt: 0 }, + 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). - amount: { lt: 0 }, + 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. @@ -129,7 +129,7 @@ export namespace LedgerAccountMethods { }, // Select what fields we should sum _sum: { - amount: true, + funds: true, fees: true, }, }) @@ -142,7 +142,7 @@ export namespace LedgerAccountMethods { ...balanceArray.map(balance => [ balance.ledgerAccountId, { - amount: balance._sum.amount ?? 0, + amount: balance._sum.funds ?? 0, fees: balance._sum.fees ?? 0 } ]) diff --git a/src/services/ledger/ledgerOperations/methods.ts b/src/services/ledger/ledgerOperations/methods.ts index 1948b4bb4..2cffedcc8 100644 --- a/src/services/ledger/ledgerOperations/methods.ts +++ b/src/services/ledger/ledgerOperations/methods.ts @@ -3,7 +3,6 @@ import { ServiceMethod } from "@/services/ServiceMethod" import { LedgerTransactionMethods } from "../ledgerTransactions/methods" import { PaymentMethods } from "../payments/methods" import { z } from "zod" -import { ManualTransferMethods } from "../manualTransfers/methods" // `LedgerOperations` provides functions to orchestrate account related actions, // such as depositing funds or creating payouts. If the ledger is needed for @@ -23,16 +22,16 @@ export namespace LedgerOperationMethods { auther: () => RequireNothing.staticFields({}).dynamicFields({}), opensTransaction: true, paramsSchema: z.object({ - amount: z.number().positive(), + funds: z.number().positive(), ledgerAccountId: z.number(), }), method: async ({ prisma, session, params }) => { const [payment, transaction] = await prisma.$transaction(async tx => { - const payment = await PaymentMethods.create.client(tx).execute({ + const payment = await PaymentMethods.create({ params: { ...params, - description: 'Innskudd', - descriptor: 'Innskudd', + descriptionLong: 'Innskudd', + descriptionShort: 'Innskudd', provider: 'STRIPE', }, session, @@ -71,17 +70,20 @@ export namespace LedgerOperationMethods { export const createPayout = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ - amount: z.number().positive(), + funds: z.number().positive(), fees: z.number().positive(), ledgerAccountId: z.number(), }), opensTransaction: true, method: async ({ prisma, params, session }) => { return prisma.$transaction(async tx => { - const manualTransfer = await ManualTransferMethods.create.client(tx).execute({ + const payment = await PaymentMethods.create.client(tx).execute({ params: { - amount: -params.amount, + provider: 'MANUAL', + descriptionShort: 'Utbetaling', + funds: -params.funds, fees: -params.fees, + details: {}, }, session, }) @@ -91,9 +93,9 @@ export namespace LedgerOperationMethods { purpose: 'PAYOUT', ledgerEntries: [{ ledgerAccountId: params.ledgerAccountId, - amount: -params.amount, + funds: -params.funds, }], - manualTransferId: manualTransfer.id, + paymentId: payment.id, }, session, }) diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/Type.ts index 1d204bf3f..f5b428506 100644 --- a/src/services/ledger/ledgerTransactions/Type.ts +++ b/src/services/ledger/ledgerTransactions/Type.ts @@ -4,6 +4,5 @@ export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ include: { ledgerEntries: true, payment: true, - manualTransfer: true, } }> diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts index a34c79993..de48ed138 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -1,4 +1,3 @@ -import { ManualTransfer, Payment } from "@prisma/client" import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" /** @@ -23,58 +22,55 @@ export function feesFormula(entryAmount: number, totalAmount: number, totalFees: } /** - * Calculates the fees for debit ledger entries (amount < 0) based on + * Calculates the fees for debit ledger entries (funds < 0) based on * the balances of the accounts which are deducted. */ -export function calculateDebitFees(ledgerEntries: { amount: number, ledgerAccountId: number }[], balances: BalanceRecord) { - const debitLedgerEntries = ledgerEntries.filter(entry => entry.amount < 0) +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.amount, balance.amount, balance.fees)] + return [entry.ledgerAccountId, feesFormula(entry.funds, balance.amount, balance.fees)] })) } /** - * Calculates the fees for credit ledger entries (amount > 0) based on + * 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: { amount: number, fees: number | null, ledgerAccountId: number }[], - payment: Payment | null, - manualTransfer: ManualTransfer | null + 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.amount > 0) - const debitLedgerEntries = ledgerEntries.filter(entry => entry.amount < 0) + + 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 totalAmount = sum( - ...debitLedgerEntries.map(entry => -entry.amount), - payment?.amount, - manualTransfer?.amount, + let totalFunds = sum( + ...debitLedgerEntries.map(entry => -entry.funds), + payment?.funds, ) let totalFees = sum( ...debitLedgerEntries.map(entry => -(entry.fees ?? 0)), payment?.fees, - manualTransfer?.fees, ) return Object.fromEntries(creditLedgerEntries.map(entry => { - const fees = feesFormula(entry.amount, totalAmount, totalFees) + 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`. - totalAmount -= entry.amount + 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 index b9a8754cd..063913b54 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -18,11 +18,11 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans // since fees aren't set earlier. const rules: LedgerTransactionRule[] = [ noTerminalState, - noFailedPayment, + noFailedTransfer, amountAndFeesHaveSameSigns, validAmountSum, sufficientBalances, - paymentComplete, + transfersComplete, noNullFees, validFeesSum, ] @@ -35,6 +35,7 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans return { state: 'SUCCEEDED' } } + /** * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) * can never change state. @@ -46,11 +47,11 @@ function noTerminalState({ state }: ExpandedLedgerTransaction): LedgerTransactio /** * If any payment has failed, the entire transaction has failed. */ -function noFailedPayment({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function noFailedTransfer({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { const okStates: PaymentState[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] - const hasFailedPayment = payment && !okStates.includes(payment.state) + const hasFailedTransfer = payment && !okStates.includes(payment.state) - if (hasFailedPayment) return { state: 'FAILED', reason: 'Betaling mislyktes.' } + if (hasFailedTransfer) return { state: 'FAILED', reason: 'Betaling mislyktes.' } } /** @@ -58,16 +59,15 @@ function noFailedPayment({ payment }: ExpandedLedgerTransaction): LedgerTransact * * Mathematically: `amount > 0 <=> fees > 0` and `amount < 0 <=> fees < 0`. */ -function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function amountAndFeesHaveSameSigns({ ledgerEntries, payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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 validPayment = sameSigns(payment?.amount, payment?.fees) - const validManualTransfer = sameSigns(manualTransfer?.amount, manualTransfer?.fees) - const validLedgerEntries = ledgerEntries.every(entry => sameSigns(entry.amount, entry.fees)) + const validEntries = ledgerEntries.every(entry => sameSigns(entry.funds, entry.fees)) + const validTransfer = !payment || sameSigns(payment.funds, payment.fees) - if (!validManualTransfer || !validPayment || !validLedgerEntries) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } + if (!validEntries || !validTransfer) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } } @@ -75,14 +75,13 @@ function amountAndFeesHaveSameSigns({ ledgerEntries, payment, manualTransfer }: * 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, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function validAmountSum({ ledgerEntries, payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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 ledgerEntriesAmountSum = ledgerEntries.reduce((sum, entry) => sum + entry.amount, 0) - const paymentAmount = payment?.amount ?? 0 - const manualTransferAmount = manualTransfer?.amount ?? 0 + const totalLedgerEntryFunds = ledgerEntries.reduce((sum, entry) => sum + entry.funds, 0) + const paymentFunds = payment?.funds ?? 0 - if (ledgerEntriesAmountSum !== paymentAmount + manualTransferAmount) return { state: 'FAILED', reason: 'Ugyldig sum av beløp.' } + if (totalLedgerEntryFunds !== paymentFunds) return { state: 'FAILED', reason: 'Ugyldig totalbeløp.' } } /** @@ -90,7 +89,7 @@ function validAmountSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedg * have a positive balance after the transaction succeeds. */ function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balances: BalanceRecord): LedgerTransactionTransition | undefined { - const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.amount < 0).map(entry => entry.ledgerAccountId) + const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.funds < 0).map(entry => entry.ledgerAccountId) const debitBalances = debitLedgerAccountIds.map(id => balances[id]) if (debitBalances.some(balance => !balance)) { @@ -105,22 +104,21 @@ function sufficientBalances({ ledgerEntries }: ExpandedLedgerTransaction, balanc /** * If any payment is pending, the transaction is pending. */ -function paymentComplete({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function transfersComplete({ payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // Since we have checked for failure states above, - // we can simply check that the transaction has not succeeded. - const hasPendingPayment = payment && payment.state !== 'SUCCEEDED' + // we can simply check that the transfer has not succeeded. + const hasPendingTransfer = payment && payment.state !== 'SUCCEEDED' - if (hasPendingPayment) return { state: 'PENDING' } + if (hasPendingTransfer) return { state: 'PENDING' } } /** * All fees must be non-null. */ -function noNullFees({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function noNullFees({ ledgerEntries, payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { const hasNullFees = ledgerEntries.some(entry => entry.fees === null) || - payment && payment.fees === null || - manualTransfer && manualTransfer.fees === null + payment?.fees === null if (hasNullFees) return { state: 'FAILED', reason: 'Manglende gebyrer.' } } @@ -128,12 +126,11 @@ function noNullFees({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTr /** * Fees must also follow Kirchhoff's first law. */ -function validFeesSum({ ledgerEntries, payment, manualTransfer }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +function validFeesSum({ ledgerEntries, payment }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { // 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 ledgerEntriesFeesSum = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) - const paymentFees = payment?.fees ?? 0 - const manualTransferFees = manualTransfer?.fees ?? 0 + const totalLedgerEntryFees = ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = payment?.fees ?? 0 - if (ledgerEntriesFeesSum !== paymentFees + manualTransferFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } + if (totalLedgerEntryFees !== paymentFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } } diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index cd1d08e6b..33a5dcf4d 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -27,7 +27,6 @@ export namespace LedgerTransactionMethods { include: { ledgerEntries: true, payment: true, - manualTransfer: true, }, }) @@ -60,7 +59,6 @@ export namespace LedgerTransactionMethods { include: { ledgerEntries: true, payment: true, - manualTransfer: true, }, orderBy: { createdAt: 'desc', @@ -83,15 +81,14 @@ export namespace LedgerTransactionMethods { paramsSchema: z.object({ purpose: z.nativeEnum(LedgerTransactionPurpose), ledgerEntries: z.object({ - amount: z.number(), + funds: z.number(), ledgerAccountId: z.number(), }).array(), paymentId: z.number().optional(), - manualTransferId: z.number().optional(), }), method: async ({ prisma, session, params }, ) => { // Calculate the balance for all accounts which are going to be deducted - const debitEntries = params.ledgerEntries.filter(entry => entry.amount < 0) + const debitEntries = params.ledgerEntries.filter(entry => entry.funds < 0) const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, session: null, @@ -100,7 +97,7 @@ export namespace LedgerTransactionMethods { // 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.amount < 0) + 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.') } @@ -119,8 +116,7 @@ export namespace LedgerTransactionMethods { ledgerEntries: { create: entries, }, - paymentId :params.paymentId, - manualTransferId: params.manualTransferId, + paymentId: params.paymentId, }, select: { id: true, @@ -158,10 +154,10 @@ export namespace LedgerTransactionMethods { session, }) - const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment, transaction.manualTransfer) + const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) if (creditFees) { - const creditEntries = transaction.ledgerEntries.filter(entry => entry.amount > 0) + const creditEntries = transaction.ledgerEntries.filter(entry => entry.funds > 0) const ledgerEntryUpdateInput = creditEntries.map(entry => ({ where: { diff --git a/src/services/ledger/manualTransfers/methods.ts b/src/services/ledger/manualTransfers/methods.ts deleted file mode 100644 index 738c1e19a..000000000 --- a/src/services/ledger/manualTransfers/methods.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing"; -import { ServiceMethod } from "@/services/ServiceMethod"; -import { z } from 'zod' - -export namespace ManualTransferMethods { - export const create = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Fix - paramsSchema: z.object({ - amount: z.number().int(), - fees: z.number().int(), - bankAccountNumber: z.string().optional(), - comment: z.string().optional(), - }), - method: ({ prisma, params }) => prisma.manualTransfer.create({ data: params }), - }) -} diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts index 8d465fa13..804986f2f 100644 --- a/src/services/ledger/payments/methods.ts +++ b/src/services/ledger/payments/methods.ts @@ -14,19 +14,45 @@ export namespace PaymentMethods { */ export const create = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - paramsSchema: z.object({ - amount: z.number(), - provider: z.nativeEnum(PaymentProvider), - description: z.string(), - descriptor: z.string().max(22), - ledgerAccountId: z.number().optional(), - }), + paramsSchema: z.intersection( + z.object({ + funds: z.number(), + fees: z.number().optional(), + 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(), + }).optional(), + }), + ]), + ), method: async ({ prisma, params }) => { + const { details = {}, ...paymentData } = params + return prisma.payment.create({ data: { - ...params, + ...paymentData, // Manual payments are automatically succeeded state: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', + stripePayment: params.provider === 'STRIPE' ? { + create: params.details, + } : undefined, + manualPayment: params.provider === 'MANUAL' ? { + create: params.details, + } : undefined, + }, + include: { + stripePayment: true, + manualPayment: true, } }) }, @@ -51,11 +77,11 @@ export namespace PaymentMethods { id: params.paymentId, }, select: { - amount: true, + funds: true, provider: true, state: true, - description: true, - descriptor: true, + descriptionLong: true, + descriptionShort: true, }, }) @@ -69,10 +95,10 @@ export namespace PaymentMethods { case 'STRIPE': const paymentIntent = await stripe.paymentIntents.create({ - amount: payment.amount, + amount: payment.funds, currency: 'nok', - description: payment.description, - statement_descriptor_suffix: payment.descriptor, + 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. @@ -95,9 +121,17 @@ export namespace PaymentMethods { id: params.paymentId, }, data: { - paymentIntentId: paymentIntent.id, + stripePayment: { + update: { + paymentIntentId: paymentIntent.id, + }, + }, state: 'PROCESSING', }, + include: { + stripePayment: true, + manualPayment: true, + } }) default: @@ -105,125 +139,4 @@ export namespace PaymentMethods { } }, }) - - // // TODO: Find a clean way to reuse logic from ledger account! - - // /** - // * Calculates the balance and fees of a payment. Optionally takes a transaction ID to calculate the balance up until that transaction. - // * - // * @warning Non-existent payments will be treated as having a balance of zero. - // * - // * @param params.ids The IDs of the payments to calculate the balance for. - // * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. - // * - // * @returns The balances of the payments. - // */ - // export const calculateBalances = ServiceMethod({ - // auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - // paramsSchema: z.object({ - // ids: z.number().array(), - // atTransactionId: z.number().optional(), - // }), - // method: async ({ prisma, params }) => { - // const balanceArray = await prisma.ledgerEntry.groupBy({ - // by: ['paymentId'], - // where: { - // // Select which payments we want to calculate the balance for - // paymentId: { - // not: null, - // 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, - // }, - // // 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: { status: { in: ['PENDING', 'SUCCEEDED'] } }, - // }, - // // Select what fields we should sum - // _sum: { - // amount: true, - // fees: true, - // }, - // }) - - // const amounts = await prisma.payment.findMany({ - // where: { - // id: { - // in: params.ids, - // }, - // }, - // select: { - // amount: true, - // fees: true, - // }, - // }) - - // // The output from the Prisma `groupBy` method is an array. - // // We convert it to an object (record) as it is more sensible for lookups. - // const balanceRecord = Object.fromEntries( - // // The "as const"s are required so that TS understands that the arrays - // // have a length of exactly two. - // [ - // // Set all ids to zero by default in case some payments do not have - // // any ledger entries yet. - // ...params.ids.map(id => [id, { amount: 0, fees: 0 }] as const), - // // Map the array returned by `groupBy` to key value pairs. - // ...balanceArray.map(balance => [ - // // The "!" is required because the typing for `fromEntries` doesn't accept - // // null as a key. (Even though all keys just get converted to strings during - // // runtime.) The query above guarantees that the id can never be null so - // // its safe anyhow. - // balance.paymentId!, - // { - // // Prisma sets sum to "null" in case the only rows which exist are null. - // // For our case we can treat it as zero. - // amount: balance._sum.amount ?? 0 + balance.amount, - // fees: balance._sum.fees ?? 0 + (balance.fees ?? 0), - // }, - // ] as const), - // ] - // ) - - // // The object returned by `fromEntries` assumes that all keys map to the provided type. - // // This is obviously not true so we need to assert the record as partial. - // return balanceRecord as Partial - // } - // }) - - // /** - // * Calcultates the balance of a single payment. Under the hood it simply uses `calculateBalances`. - // * - // * @warning In case a payment with the provided id doesn't exist a balance of zero will be returned! - // * - // * @param params.id The ID of the payment to calculate the balance for. - // * @param params.atTransactionId Optional transaction ID to calculate the balance up until that transaction. - // * - // * @returns The balances of the payments. - // */ - // export const calculateBalance = ServiceMethod({ - // auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - // paramsSchema: z.object({ - // id: z.number(), - // atTransactionId: z.number().optional(), - // }), - // method: async ({ prisma, session, params }) => { - // const balances = await calculateBalances.client(prisma).execute({ - // params: { - // ids: [params.id], - // atTransactionId: params.atTransactionId, - // }, - // session, - // }) - - // // We know that the returned balances must contain the id we provided. - // // So, we can simply assert that this is not undefined. - // // TODO: There might be a better way to do this? - // return balances[params.id]! - // } - // }) } diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts index 31c670f78..771162489 100644 --- a/src/services/ledger/payments/stripeWebhookCallback.ts +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -82,28 +82,34 @@ export async function stripeWebhookCallback(event: Stripe.Event): Promise Date: Fri, 29 Aug 2025 19:59:49 +0200 Subject: [PATCH 33/62] feat: ui and the like --- .../Ledger/CheckoutModal.module.scss | 7 + .../_components/Ledger/CheckoutModalOld.tsx | 231 ++++++++++++++++++ .../Ledger/DepositModal.module.scss | 10 + src/app/_components/Ledger/DepositModal.tsx | 123 ++++++++++ .../Ledger/LedgerAccountBalance.module.scss | 45 +++- .../Ledger/LedgerAccountBalance.tsx | 12 +- .../Ledger/ManualPaymentIntput.tsx | 16 ++ .../Ledger/PayoutModal.module.scss | 14 ++ src/app/_components/Ledger/PayoutModal.tsx | 47 ++++ .../TransactionList/TransactionList.tsx | 26 +- src/app/_components/Stripe/DepositForm.tsx | 28 --- src/app/_components/Stripe/PaymentForm.tsx | 56 ----- src/app/_components/Stripe/StripePayment.tsx | 41 ++++ ...PaymentProvider.tsx => StripeProvider.tsx} | 6 +- src/app/_components/UI/NumberInput.tsx | 2 +- .../(user-admin)/account/BankCardModal.tsx | 5 +- .../(user-admin)/account/CheckOutModal.tsx | 47 ++++ .../[username]/(user-admin)/account/page.tsx | 21 +- .../account/transactions/page.tsx | 2 +- src/lib/currency/convert.ts | 6 +- src/prisma/schema/ledger.prisma | 2 + .../seeder/src/development/seedDevGroups.ts | 5 + .../seeder/src/development/seedDevUsers.ts | 10 + src/services/ledger/ledgerAccount/actions.ts | 6 + .../ledger/ledgerOperations/actions.ts | 7 + .../ledger/ledgerOperations/methods.ts | 49 ++-- .../ledger/ledgerOperations/schemas.ts | 12 + .../ledger/ledgerTransactions/Type.ts | 7 +- .../ledger/ledgerTransactions/methods.ts | 7 +- src/services/ledger/payments/Types.ts | 8 + src/services/ledger/payments/methods.ts | 3 +- .../ledger/ledgerTransactions.test.ts | 15 +- 32 files changed, 723 insertions(+), 153 deletions(-) create mode 100644 src/app/_components/Ledger/CheckoutModal.module.scss create mode 100644 src/app/_components/Ledger/CheckoutModalOld.tsx create mode 100644 src/app/_components/Ledger/DepositModal.module.scss create mode 100644 src/app/_components/Ledger/DepositModal.tsx create mode 100644 src/app/_components/Ledger/ManualPaymentIntput.tsx create mode 100644 src/app/_components/Ledger/PayoutModal.module.scss create mode 100644 src/app/_components/Ledger/PayoutModal.tsx delete mode 100644 src/app/_components/Stripe/DepositForm.tsx delete mode 100644 src/app/_components/Stripe/PaymentForm.tsx create mode 100644 src/app/_components/Stripe/StripePayment.tsx rename src/app/_components/Stripe/{PaymentProvider.tsx => StripeProvider.tsx} (86%) create mode 100644 src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx create mode 100644 src/services/ledger/ledgerAccount/actions.ts create mode 100644 src/services/ledger/ledgerOperations/actions.ts create mode 100644 src/services/ledger/ledgerOperations/schemas.ts create mode 100644 src/services/ledger/payments/Types.ts diff --git a/src/app/_components/Ledger/CheckoutModal.module.scss b/src/app/_components/Ledger/CheckoutModal.module.scss new file mode 100644 index 000000000..dae5e9524 --- /dev/null +++ b/src/app/_components/Ledger/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/CheckoutModalOld.tsx b/src/app/_components/Ledger/CheckoutModalOld.tsx new file mode 100644 index 000000000..65b8bb7f5 --- /dev/null +++ b/src/app/_components/Ledger/CheckoutModalOld.tsx @@ -0,0 +1,231 @@ +'use client' +import React, { lazy, Ref, useRef } from "react" +import styles from "./CheckoutModal.module.scss" +import { PaymentProvider } from "@prisma/client" +import Form from "@/components/Form/Form" +import { ActionReturn } from "@/actions/Types" +import { useState } from "react" +import PopUp from "@/components/PopUp/PopUp" +import { ExpandedLedgerTransaction } from "@/services/ledger/ledgerTransactions/Type" +import Button from "@/components/UI/Button" +import { StripePaymentRef } from "../Stripe/StripePayment" +import { createActionError } from "@/actions/error" + +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}
+// )} +//
+// } \ No newline at end of file diff --git a/src/app/_components/Ledger/DepositModal.module.scss b/src/app/_components/Ledger/DepositModal.module.scss new file mode 100644 index 000000000..6541f78f8 --- /dev/null +++ b/src/app/_components/Ledger/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/DepositModal.tsx b/src/app/_components/Ledger/DepositModal.tsx new file mode 100644 index 000000000..263ed6a36 --- /dev/null +++ b/src/app/_components/Ledger/DepositModal.tsx @@ -0,0 +1,123 @@ +'use client' + +import styles from "./DepositModal.module.scss" +import { lazy, useRef, useState } from "react"; +import Form from "../Form/Form"; +import PopUp from "../PopUp/PopUp"; +import NumberInput from "../UI/NumberInput"; +import { createDepositAction } from "@/services/ledger/ledgerOperations/actions"; +import { createActionError } from "@/actions/error"; +import type { StripePaymentRef } from "../Stripe/StripePayment"; +import { PaymentProvider } from "@prisma/client"; +import Button from "../UI/Button"; +import { ExpandedPayment } from "@/services/ledger/payments/Types"; +import { convertAmount, displayAmount } from "@/lib/currency/convert"; + +// Avoid loading the Stripe components until they are needed +const StripePayment = lazy(() => import("../Stripe/StripePayment")); +const StripeProvider = lazy(() => import("../Stripe/StripeProvider")); + +const minFunds = 50_00 + +const defaultPaymentProvider: PaymentProvider = "STRIPE" +const paymentProviderNames: Record = { + STRIPE: "Stripe", + MANUAL: "Manuell Betaling", +} + +type Props = { + ledgerAccountId: number, +} + +export default function DepositModal({ ledgerAccountId }: Props) { + const [funds, setFunds] = useState(minFunds) + 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.confirm(clientSecret) + if (confirmError) return confirmError + } + + const handleSubmit = async (data: 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 }> +
+
+ setFunds(convertAmount(e.target.value))} + /> + +
+ Betal med... + + {Object.entries(paymentProviderNames).map(([provider, name]) => ( + + ))} +
+ + {selectedProvider === "STRIPE" && ( + + + + )} + + {selectedProvider === "MANUAL" && ( +
+

Etter innsending vil du motta instruksjoner for manuell betaling.

+
+ )} + +
+
+} diff --git a/src/app/_components/Ledger/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/LedgerAccountBalance.module.scss index fbebff1b2..f4ee09411 100644 --- a/src/app/_components/Ledger/LedgerAccountBalance.module.scss +++ b/src/app/_components/Ledger/LedgerAccountBalance.module.scss @@ -1,14 +1,41 @@ -.LedgerAccountBalance { - // Temporarly empty +@use "@/styles/ohma"; - // p { - // display: grid; +.LedgerAccountBalance { + display: grid; + grid-template-columns: auto 1fr; + 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; - // // gap: 0.5rem; - // // margin: 0.25rem 0; + // } + // 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 +} + +.currencySymbol { + display: none; + - // span { - // min-width: 80px; // forces alignment - // } + // @include ohma.screenMobile { + // display: none; // } } \ No newline at end of file diff --git a/src/app/_components/Ledger/LedgerAccountBalance.tsx b/src/app/_components/Ledger/LedgerAccountBalance.tsx index 9b64ccc0c..fc72cfc17 100644 --- a/src/app/_components/Ledger/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/LedgerAccountBalance.tsx @@ -12,7 +12,15 @@ export default async function LedgerAccountBalance({ accountId, showFees }: Prop const balance = { amount: 100, fees: 2 } // unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) return
-

Balanse: {displayAmount(balance.amount)} Kluengende muent

- {showFees &&

Avgifter: {displayAmount(balance.fees)} Kluengende muent

} +
+
Saldo
+
{displayAmount(balance.amount)}
+
Kluengende Muente
+
+ {showFees &&
+
Avgifter
+
{displayAmount(balance.fees)}
+
Kluengende Muente
+
}
} diff --git a/src/app/_components/Ledger/ManualPaymentIntput.tsx b/src/app/_components/Ledger/ManualPaymentIntput.tsx new file mode 100644 index 000000000..e6fcb997e --- /dev/null +++ b/src/app/_components/Ledger/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 ( +
+ + + +
+ ) +} \ No newline at end of file diff --git a/src/app/_components/Ledger/PayoutModal.module.scss b/src/app/_components/Ledger/PayoutModal.module.scss new file mode 100644 index 000000000..ca4dec49c --- /dev/null +++ b/src/app/_components/Ledger/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/PayoutModal.tsx b/src/app/_components/Ledger/PayoutModal.tsx new file mode 100644 index 000000000..079548f85 --- /dev/null +++ b/src/app/_components/Ledger/PayoutModal.tsx @@ -0,0 +1,47 @@ +'use client' + +import styles from "./PayoutModal.module.scss" +import Form from "../Form/Form"; +import PopUp from "../PopUp/PopUp"; +import NumberInput from "../UI/NumberInput"; +import { createPayout } from "@/services/ledger/ledgerOperations/actions"; +import Button from "../UI/Button"; +import { convertAmount } from "@/lib/currency/convert"; +import { useState } from "react"; +import { bindParams } from "@/actions/bind"; + +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 }> +
+
+ setFunds(convertAmount(e.target.value))} + /> + setFees(convertAmount(e.target.value))} + /> + +
+
+} diff --git a/src/app/_components/Ledger/TransactionList/TransactionList.tsx b/src/app/_components/Ledger/TransactionList/TransactionList.tsx index 5124dab07..c34fb5056 100644 --- a/src/app/_components/Ledger/TransactionList/TransactionList.tsx +++ b/src/app/_components/Ledger/TransactionList/TransactionList.tsx @@ -1,8 +1,8 @@ 'use client' -import TransactionRow from './TransactionRow' -import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' -import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +// import TransactionRow from './TransactionRow' +// import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' +// import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' type Props = { accountId: number, @@ -10,12 +10,16 @@ type Props = { } export default function TransactionList({ accountId }: Props) { - return - - } - /> - + return
+ Transaction list for account {accountId} +
+ + // return + // + // } + // /> + // } diff --git a/src/app/_components/Stripe/DepositForm.tsx b/src/app/_components/Stripe/DepositForm.tsx deleted file mode 100644 index ae43bb423..000000000 --- a/src/app/_components/Stripe/DepositForm.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client' - -import PaymentProvider from './PaymentProvider' -import PaymentForm from './PaymentForm' -import NumberInput from '@/components/UI/NumberInput' -import { useState } from 'react' -import type { ChangeEvent } from 'react' - -type Props = { - accountId: number, -} - -export default function DepositForm({ accountId }: Props) { - const [depositAmount, setDepositAmount] = useState() - - const onChange = (event: ChangeEvent) => { - setDepositAmount(Number(event.target.value) * 100) - } - - return
-
- - - - - -
-} diff --git a/src/app/_components/Stripe/PaymentForm.tsx b/src/app/_components/Stripe/PaymentForm.tsx deleted file mode 100644 index 13212834b..000000000 --- a/src/app/_components/Stripe/PaymentForm.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -import Form from '@/components/Form/Form' -// import { createStripeDeposit } from '@/actions/ledger/transactions/deposits' -import { createActionError } from '@/actions/error' -import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' -import React from 'react' - -type Props = { - accountId: number, - children?: React.ReactNode, -} - -export default function PaymentForm({ accountId, children }: Props) { - const stripe = useStripe() - const elements = useElements() - - const handleSubmit = async (formData: FormData) => { - if (!stripe || !elements) { - return createActionError('BAD DATA') - } - - const { error: submitError } = await elements.submit() - if (submitError) { - return createActionError('BAD DATA', '') - } - - const deposit = { success: true, data: {} as any } as const //await createStripeDeposit({ accountId }, formData) - - if (!deposit.success) { - return deposit - } - - const { error: confirmationError } = await stripe.confirmPayment({ - clientSecret: deposit.data.clientSecret, - elements, - confirmParams: { - return_url: window.location.href, - }, - // redirect: 'if_required', - }) - - if (confirmationError) { - return createActionError('UNKNOWN ERROR', confirmationError.message) - } - - return { - success: true, - } as const - } - - return
- {children} - - -} diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx new file mode 100644 index 000000000..224c0ef37 --- /dev/null +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -0,0 +1,41 @@ +import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; +import React, { useImperativeHandle } from "react"; + +export type StripePaymentRef = { + submit: () => Promise; + confirm: (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.' + }, + confirm: 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.' + } + })) + + return +} \ No newline at end of file diff --git a/src/app/_components/Stripe/PaymentProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx similarity index 86% rename from src/app/_components/Stripe/PaymentProvider.tsx rename to src/app/_components/Stripe/StripeProvider.tsx index a4f4156db..4b7303198 100644 --- a/src/app/_components/Stripe/PaymentProvider.tsx +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -15,8 +15,8 @@ type Props = { children?: ReactNode } -export default function PaymentProvider({ children, amount }: Props) { - return <>{ +export default function StripeProvider({ children, amount }: Props) { + return ( {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/users/[username]/(user-admin)/account/BankCardModal.tsx b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx index fa7b4d1d6..81492a5b9 100644 --- a/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx +++ b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx @@ -2,12 +2,14 @@ import PopUp from "@/app/_components/PopUp/PopUp"; import Button from "@/app/_components/UI/Button"; +import { CardElement } from "@stripe/react-stripe-js"; type PropTypes = { userId: number, } export default function BankCardModal({userId }: PropTypes) { + return (

Legg til bankkort

TODO

+ {/* */}
) -} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx b/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx new file mode 100644 index 000000000..840225ae0 --- /dev/null +++ b/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx @@ -0,0 +1,47 @@ +"use client" + +import { createUserAction } from "@/actions/users/create" +import PopUp from "@/app/_components/PopUp/PopUp" +import Button from "@/app/_components/UI/Button" +import Form from "@/components/Form/Form" +import { PaymentProvider } from "@prisma/client" + +type PropTypes = { + title: string, + children?: React.ReactNode, +} + +const defaultPaymentProvider: PaymentProvider = "STRIPE" + +const paymentProviders: Record = { + STRIPE: "Stripe", + MANUAL: "Manuell betaling", +} + +export default function CheckoutModal({ title, children }: PropTypes) { + return ( + } + > +

1 kg. Maragin

+

1 kg. Farin

+

1 ts. Pepper

+ {children} +
+
+ Betal med... + + {Object.entries(paymentProviders).map(([provider, name]) => ( + + ))} +
+
+ + +
+ ) +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 522ed11b0..3b6e77dcc 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -5,10 +5,11 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/getUser' import Button from '@/components/UI/Button' import Link from 'next/link' -import PayoutModal from './PayoutModal' -import DepositModal from './DepositModal' import Card from './Card' import BankCardModal from './BankCardModal' +import DepositModal from '@/components/Ledger/DepositModal' +import PayoutModal from '@/components/Ledger/PayoutModal' +import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' export default async function Account() { const session = await getUser({ @@ -18,12 +19,20 @@ export default async function Account() { const account = { id: 1 }; //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id })) + return
-

Konto

- - - +

Kontooversikt

+

Saldo: 69 Kluengende Muente

+

Avgifter: 69 Kluengende Muente

+ + + {/*

Konto

*/} + {/* */} +

+ {/* */} + {/* */}
{/* RequireNothing.staticFields({}).dynamicFields({}), opensTransaction: true, paramsSchema: z.object({ - funds: z.number().positive(), ledgerAccountId: z.number(), + provider: z.nativeEnum(PaymentProvider), + funds: z.coerce.number().positive(), }), - method: async ({ prisma, session, params }) => { - const [payment, transaction] = await prisma.$transaction(async tx => { + method: async ({ prisma, params }) => { + const transaction = await prisma.$transaction(async tx => { const payment = await PaymentMethods.create({ params: { - ...params, + provider: params.provider, + funds: params.funds, descriptionLong: 'Innskudd', descriptionShort: 'Innskudd', - provider: 'STRIPE', }, - session, }) - const transaction = await LedgerTransactionMethods.create.client(tx).execute({ + const transaction = await LedgerTransactionMethods.create({ params: { purpose: 'DEPOSIT', - ledgerEntries: [params], + ledgerEntries: [{ + ledgerAccountId: params.ledgerAccountId, + funds: params.funds, + }], paymentId: payment.id, }, - session, }) - return [payment, transaction] + return transaction }) - transaction.payment = await PaymentMethods.initiate.client(prisma).execute({ - params: { paymentId: payment.id }, - session, - }) + if (transaction.payment?.state === 'PENDING') { + transaction.payment = await PaymentMethods.initiate({ + params: { paymentId: transaction.payment.id }, + }) + } return transaction } @@ -70,25 +74,23 @@ export namespace LedgerOperationMethods { export const createPayout = ServiceMethod({ auther: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ - funds: z.number().positive(), - fees: z.number().positive(), 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, session }) => { + method: async ({ prisma, params }) => { return prisma.$transaction(async tx => { - const payment = await PaymentMethods.create.client(tx).execute({ + const payment = await PaymentMethods.create({ params: { provider: 'MANUAL', + descriptionLong: 'Utbetaling', descriptionShort: 'Utbetaling', funds: -params.funds, - fees: -params.fees, - details: {}, }, - session, }) - const transaction = await LedgerTransactionMethods.create.client(tx).execute({ + const transaction = await LedgerTransactionMethods.create({ params: { purpose: 'PAYOUT', ledgerEntries: [{ @@ -97,7 +99,6 @@ export namespace LedgerOperationMethods { }], paymentId: payment.id, }, - session, }) return transaction diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/ledgerOperations/schemas.ts new file mode 100644 index 000000000..e7f0ede55 --- /dev/null +++ b/src/services/ledger/ledgerOperations/schemas.ts @@ -0,0 +1,12 @@ +import { PaymentProvider } from "@prisma/client"; +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."); +} \ No newline at end of file diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/Type.ts index f5b428506..602d49910 100644 --- a/src/services/ledger/ledgerTransactions/Type.ts +++ b/src/services/ledger/ledgerTransactions/Type.ts @@ -3,6 +3,11 @@ import type { Prisma } from "@prisma/client"; export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ include: { ledgerEntries: true, - payment: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, } }> diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index 33a5dcf4d..60a25e6a6 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -26,7 +26,12 @@ export namespace LedgerTransactionMethods { }, include: { ledgerEntries: true, - payment: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, }, }) diff --git a/src/services/ledger/payments/Types.ts b/src/services/ledger/payments/Types.ts new file mode 100644 index 000000000..ab2b5583e --- /dev/null +++ b/src/services/ledger/payments/Types.ts @@ -0,0 +1,8 @@ +import { Prisma } from "@prisma/client"; + +export type ExpandedPayment = Prisma.PaymentGetPayload<{ + include: { + stripePayment: true, + manualPayment: true, + }, +}> diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts index 804986f2f..9d289d07b 100644 --- a/src/services/ledger/payments/methods.ts +++ b/src/services/ledger/payments/methods.ts @@ -17,7 +17,6 @@ export namespace PaymentMethods { paramsSchema: z.intersection( z.object({ funds: z.number(), - fees: z.number().optional(), descriptionLong: z.string().optional(), descriptionShort: z.string().optional(), ledgerAccountId: z.number().optional(), @@ -31,6 +30,7 @@ export namespace PaymentMethods { provider: z.literal(PaymentProvider.MANUAL), details: z.object({ bankAccountNumber: z.string().optional(), + fees: z.number().nonnegative().default(0), }).optional(), }), ]), @@ -43,6 +43,7 @@ export namespace PaymentMethods { ...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, diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index f0ec66060..e156b1588 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -1,6 +1,6 @@ import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/methods' -import { ManualTransferMethods } from '@/services/ledger/manualTransfers/methods' +import { PaymentMethods } from '@/services/ledger/payments/methods' import { UserMethods } from '@/services/users/methods' import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' import { allSettledOrThrow } from 'tests/utils' @@ -50,10 +50,11 @@ describe('ledger transactions', () => { describe('internal transactions', () => { beforeEach(async () => { await allSettledOrThrow(testAccountIds.map(async accountId => { - const manualTransfer = await ManualTransferMethods.create({ + const manualPayment = await PaymentMethods.create({ params: { - amount: INITIAL_BALANCE.amount, + funds: INITIAL_BALANCE.amount, fees: INITIAL_BALANCE.fees, + provider: 'MANUAL', }, }) @@ -62,9 +63,9 @@ describe('ledger transactions', () => { purpose: 'DEPOSIT', ledgerEntries: [{ ledgerAccountId: accountId, - amount: INITIAL_BALANCE.amount, + funds: INITIAL_BALANCE.amount, }], - manualTransferId: manualTransfer.id, + paymentId: manualPayment.id, } }) }) @@ -85,7 +86,7 @@ describe('ledger transactions', () => { test.each(validLedgerEntries)('valid internal transactions', async (...entries) => { const transaction = await LedgerTransactionMethods.create({ params: { - ledgerEntries: entries.map((amount, i) => ({ amount, ledgerAccountId: testAccountIds[i] })), + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), purpose: 'DEPOSIT', }, }) @@ -119,7 +120,7 @@ describe('ledger transactions', () => { test.each(invalidLedgerEntries)('invalid internal transactions', async (...entries) => { const transactionPromise = LedgerTransactionMethods.create({ params: { - ledgerEntries: entries.map((amount, i) => ({ amount, ledgerAccountId: testAccountIds[i] })), + ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), purpose: 'DEPOSIT', }, }) From 12ef564bf8b2aa443360588e78a4ca6541f51644 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 21 Sep 2025 22:00:57 +0200 Subject: [PATCH 34/62] style: linting --- .../_components/Ledger/CheckoutModalOld.tsx | 107 +++++----- src/app/_components/Ledger/DepositModal.tsx | 60 +++--- .../Ledger/ManualPaymentIntput.tsx | 6 +- src/app/_components/Ledger/PayoutModal.tsx | 28 +-- .../_components/Ledger/TransactionList.tsx | 2 +- src/app/_components/Stripe/StripePayment.tsx | 10 +- src/app/admin/accounts/page.tsx | 4 +- src/app/api/stripe-event/route.ts | 5 +- .../(user-admin)/account/BankCardModal.tsx | 11 +- .../(user-admin)/account/CheckOutModal.tsx | 22 +-- .../(user-admin)/account/DepositModal.tsx | 22 +-- .../(user-admin)/account/PayoutModal.tsx | 22 +-- .../[username]/(user-admin)/account/page.tsx | 12 +- .../users/[username]/(user-admin)/layout.tsx | 2 +- src/lib/currency/convert.ts | 2 +- src/services/ledger/ledgerAccount/Types.ts | 2 +- src/services/ledger/ledgerAccount/actions.ts | 6 +- src/services/ledger/ledgerAccount/methods.ts | 34 ++-- .../ledger/ledgerOperations/actions.ts | 4 +- .../ledger/ledgerOperations/methods.ts | 78 ++++---- .../ledger/ledgerOperations/schemas.ts | 6 +- .../ledger/ledgerTransactions/Type.ts | 2 +- .../ledgerTransactions/calculateFees.ts | 25 ++- .../determineTransactionState.ts | 80 ++++++-- .../ledger/ledgerTransactions/methods.ts | 183 +++++++++--------- src/services/ledger/payments/Types.ts | 2 +- src/services/ledger/payments/methods.ts | 24 +-- .../ledger/payments/stripeWebhookCallback.ts | 52 +++-- tests/services/context.test.ts | 48 +++-- tests/services/ledger/calculateFees.test.ts | 32 +-- .../ledger/ledgerTransactions.test.ts | 45 +++-- tests/services/ledger/payments.test.ts | 21 +- tests/services/permissions.test.ts | 19 -- tests/utils.ts | 4 +- 34 files changed, 500 insertions(+), 482 deletions(-) delete mode 100644 tests/services/permissions.test.ts diff --git a/src/app/_components/Ledger/CheckoutModalOld.tsx b/src/app/_components/Ledger/CheckoutModalOld.tsx index 65b8bb7f5..c64f4a0c8 100644 --- a/src/app/_components/Ledger/CheckoutModalOld.tsx +++ b/src/app/_components/Ledger/CheckoutModalOld.tsx @@ -1,24 +1,23 @@ 'use client' -import React, { lazy, Ref, useRef } from "react" -import styles from "./CheckoutModal.module.scss" -import { PaymentProvider } from "@prisma/client" -import Form from "@/components/Form/Form" -import { ActionReturn } from "@/actions/Types" -import { useState } from "react" -import PopUp from "@/components/PopUp/PopUp" -import { ExpandedLedgerTransaction } from "@/services/ledger/ledgerTransactions/Type" -import Button from "@/components/UI/Button" -import { StripePaymentRef } from "../Stripe/StripePayment" -import { createActionError } from "@/actions/error" - -const StripeProvider = lazy(() => import("../Stripe/StripeProvider")) -const StripePayment = lazy(() => import("../Stripe/StripePayment")) - -const defaultPaymentProvider: PaymentProvider = "STRIPE" +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 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 '@/actions/Types' +import { createActionError } from '@/actions/error' + +const StripeProvider = lazy(() => import('../Stripe/StripeProvider')) +const StripePayment = lazy(() => import('../Stripe/StripePayment')) + +const defaultPaymentProvider: PaymentProvider = 'STRIPE' const paymentProviderNames: Record = { - STRIPE: "Stripe", - MANUAL: "Manuell Betaling", + STRIPE: 'Stripe', + MANUAL: 'Manuell Betaling', } type Props = { @@ -32,19 +31,19 @@ type Props = { targetLedgerAccountId?: number, children?: React.ReactNode, } - -export default function CheckoutModal({ + +export default function CheckoutModal({ callback, - title = "Betal", - showSummary = true, - totalFunds = 100, - availableFunds = 50, - manualFees = 0, + 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) @@ -54,9 +53,9 @@ export default function CheckoutModal({ const fundsToPay = Math.max(0, totalFunds - fundsToTransfer) const handleSubmit = async (): Promise> => { - if (paymentProvider === "STRIPE") { + if (paymentProvider === 'STRIPE') { const result = await stripePaymentRef?.current?.submit() - + if (!result) { return createActionError('BAD DATA', 'Stripe er ikke initalisert enda.') } @@ -82,13 +81,13 @@ export default function CheckoutModal({ funds: fundsToPay, }, }) - + if (!result.success) return result const transaction = result.data if (transaction.payment?.state === 'PENDING') { - if (paymentProvider !== "STRIPE" || !transaction.payment?.stripePayment) { + if (paymentProvider !== 'STRIPE' || !transaction.payment?.stripePayment) { return createActionError('BAD DATA', 'Ugyldig betalingsdata fra server.') } @@ -100,34 +99,34 @@ export default function CheckoutModal({ } return ( - } >
- +
Betal med... {Object.entries(paymentProviderNames).map(([provider, name]) => (
- {/* {amountToPay > 0 ? ( + {/* {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.

// ) : ( @@ -177,7 +176,7 @@ export default function CheckoutModal({ ) } - {/* +{/*
@@ -212,11 +211,11 @@ export default function CheckoutModal({ // Betal med... // {paymentProviders.map(({ provider, name }) => ( // - + diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx index 224c0ef37..c3d9967ab 100644 --- a/src/app/_components/Stripe/StripePayment.tsx +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -1,5 +1,5 @@ -import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"; -import React, { useImperativeHandle } from "react"; +import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js' +import React, { useImperativeHandle } from 'react' export type StripePaymentRef = { submit: () => Promise; @@ -16,10 +16,10 @@ export default function StripePayment({ ref }: Props) { useImperativeHandle(ref, () => ({ submit: async () => { - if (!stripe || !elements) return "Stripe er ikke initalisert enda." + 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.' }, confirm: async (clientSecret: string) => { @@ -38,4 +38,4 @@ export default function StripePayment({ ref }: Props) { })) return -} \ No newline at end of file +} diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx index d18019fb5..0dc058f2f 100644 --- a/src/app/admin/accounts/page.tsx +++ b/src/app/admin/accounts/page.tsx @@ -1,3 +1,3 @@ export default function Accounts() { - return "Yo" -} \ No newline at end of file + return 'Yo' +} diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts index d37ba2393..990223710 100644 --- a/src/app/api/stripe-event/route.ts +++ b/src/app/api/stripe-event/route.ts @@ -15,7 +15,7 @@ export async function POST(req: Request) { } 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}`) @@ -28,8 +28,7 @@ export async function POST(req: Request) { } try { - await DepositMethods.confirmStripe.newClient().execute({ - session: null, + await DepositMethods.confirmStripe({ params: { balanceTransactionId: event.data.object.balance_transaction, paymentIntentId: event.data.object.payment_intent, diff --git a/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx index 81492a5b9..6618f5cb7 100644 --- a/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx +++ b/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx @@ -1,15 +1,14 @@ -"use client" +'use client' -import PopUp from "@/app/_components/PopUp/PopUp"; -import Button from "@/app/_components/UI/Button"; -import { CardElement } from "@stripe/react-stripe-js"; +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import { CardElement } from '@stripe/react-stripe-js' type PropTypes = { userId: number, } -export default function BankCardModal({userId }: PropTypes) { - +export default function BankCardModal({ userId }: PropTypes) { return ( = { - STRIPE: "Stripe", - MANUAL: "Manuell betaling", + STRIPE: 'Stripe', + MANUAL: 'Manuell betaling', } export default function CheckoutModal({ title, children }: PropTypes) { @@ -34,7 +34,7 @@ export default function CheckoutModal({ title, children }: PropTypes) { {Object.entries(paymentProviders).map(([provider, name]) => ( ))} @@ -44,4 +44,4 @@ export default function CheckoutModal({ title, children }: PropTypes) { ) -} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/account/DepositModal.tsx b/src/app/users/[username]/(user-admin)/account/DepositModal.tsx index 550223214..f67c19366 100644 --- a/src/app/users/[username]/(user-admin)/account/DepositModal.tsx +++ b/src/app/users/[username]/(user-admin)/account/DepositModal.tsx @@ -1,13 +1,13 @@ -"use client" +'use client' -import Form from "@/app/_components/Form/Form"; -import PopUp from "@/app/_components/PopUp/PopUp"; -import Button from "@/app/_components/UI/Button"; -import Checkbox from "@/app/_components/UI/Checkbox"; -import NumberInput from "@/app/_components/UI/NumberInput"; -import TextInput from "@/app/_components/UI/TextInput"; -import { currencySymbol } from "@/lib/currency/config"; -import { displayAmount } from "@/lib/currency/convert"; +import Form from '@/app/_components/Form/Form' +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import Checkbox from '@/app/_components/UI/Checkbox' +import NumberInput from '@/app/_components/UI/NumberInput' +import TextInput from '@/app/_components/UI/TextInput' +import { currencySymbol } from '@/lib/currency/config' +import { displayAmount } from '@/lib/currency/convert' type PropTypes = { accountId: number, @@ -22,7 +22,7 @@ export default function DepositModal({ accountId }: PropTypes) { customShowButton={(open) => } >

Sett inn {currencySymbol}

- {/* ({ success: true, data: { accountId } })} @@ -35,4 +35,4 @@ export default function DepositModal({ accountId }: PropTypes) { ) -} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx b/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx index d6e5a85d3..7ced601ca 100644 --- a/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx +++ b/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx @@ -1,13 +1,13 @@ -"use client" +'use client' -import Form from "@/app/_components/Form/Form"; -import PopUp from "@/app/_components/PopUp/PopUp"; -import Button from "@/app/_components/UI/Button"; -import Checkbox from "@/app/_components/UI/Checkbox"; -import NumberInput from "@/app/_components/UI/NumberInput"; -import TextInput from "@/app/_components/UI/TextInput"; -import { currencySymbol } from "@/lib/currency/config"; -import { displayAmount } from "@/lib/currency/convert"; +import Form from '@/app/_components/Form/Form' +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import Checkbox from '@/app/_components/UI/Checkbox' +import NumberInput from '@/app/_components/UI/NumberInput' +import TextInput from '@/app/_components/UI/TextInput' +import { currencySymbol } from '@/lib/currency/config' +import { displayAmount } from '@/lib/currency/convert' type PropTypes = { accountId: number, @@ -24,7 +24,7 @@ export default function PayoutModal({ accountId, paymentAmount, accountNumber }:

Registrer utbetaling

{paymentAmount &&

Utestående beløp: {displayAmount(paymentAmount)} {currencySymbol}

}

Oppgitt kontonummer for utbetaling: {accountNumber ? {accountNumber} : Ingen}

- ({ success: true, data: { accountId } })} @@ -35,4 +35,4 @@ export default function PayoutModal({ accountId, paymentAmount, accountNumber }: ) -} \ No newline at end of file +} diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 3b6e77dcc..260f36782 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,15 +1,15 @@ // import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' +import Card from './Card' +import BankCardModal from './BankCardModal' import LedgerAccountBalance from '@/app/_components/Ledger/LedgerAccountBalance' import TextInput from '@/app/_components/UI/TextInput' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/getUser' import Button from '@/components/UI/Button' -import Link from 'next/link' -import Card from './Card' -import BankCardModal from './BankCardModal' import DepositModal from '@/components/Ledger/DepositModal' import PayoutModal from '@/components/Ledger/PayoutModal' import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' +import Link from 'next/link' export default async function Account() { const session = await getUser({ @@ -17,7 +17,7 @@ export default async function Account() { shouldRedirect: true, }) // TODO: Replace - const account = { id: 1 }; //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) + const account = { id: 1 } //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id })) @@ -34,7 +34,7 @@ export default async function Account() { {/* */} {/* */} - {/* } > @@ -68,7 +68,7 @@ export default async function Account() {

Betalingsalternativer

Bankkort

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

diff --git a/src/app/users/[username]/(user-admin)/layout.tsx b/src/app/users/[username]/(user-admin)/layout.tsx index de6303598..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/lib/currency/convert.ts b/src/lib/currency/convert.ts index 763f94a06..81f3c6792 100644 --- a/src/lib/currency/convert.ts +++ b/src/lib/currency/convert.ts @@ -6,7 +6,7 @@ export function convertAmount(amount: string | number): number { if (typeof amount === 'string') { amount = amount.replace(',', '.') } - + return Math.round(Number(amount) * 100) } diff --git a/src/services/ledger/ledgerAccount/Types.ts b/src/services/ledger/ledgerAccount/Types.ts index 689b1e38b..34bcb86f3 100644 --- a/src/services/ledger/ledgerAccount/Types.ts +++ b/src/services/ledger/ledgerAccount/Types.ts @@ -1,4 +1,4 @@ -// NOTE: `amount` and `fees` are stored as integers representing +// 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 = { diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts index 3781bd537..84a3da7c2 100644 --- a/src/services/ledger/ledgerAccount/actions.ts +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -1,6 +1,6 @@ -"use server" +'use server' -import { action } from "@/actions/action" -import { LedgerAccountMethods } from "./methods" +import { LedgerAccountMethods } from './methods' +import { action } from '@/services/action' export const calculateLedgerAccountBalanceAction = action(LedgerAccountMethods.calculateBalance) diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 08424dcc2..d7e586d25 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -1,9 +1,9 @@ import { LedgerAccountSchemas } from './schemas' import { RequireNothing } from '@/auth/auther/RequireNothing' -import { ServiceMethod } from '@/services/ServiceMethod' import { ServerError } from '@/services/error' +import { serviceMethod } from '@/services/serviceMethod' import { z } from 'zod' -import { BalanceRecord } from './Types' +import type { BalanceRecord } from './Types' export namespace LedgerAccountMethods { /** @@ -16,8 +16,8 @@ export namespace LedgerAccountMethods { * * @returns The created account. */ - export const create = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + 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' @@ -53,8 +53,8 @@ export namespace LedgerAccountMethods { * * @returns The account details. */ - export const read = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + export const read = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.union([ z.object({ userId: z.number(), @@ -75,7 +75,7 @@ export namespace LedgerAccountMethods { if (account) return account - return create.client(prisma).execute({ session, data: params }) + return create({ session, data: params }) }, }) @@ -83,14 +83,14 @@ export namespace LedgerAccountMethods { * 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + export const calculateBalances = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ ids: z.number().array(), atTransactionId: z.number().optional(), @@ -140,10 +140,10 @@ export namespace LedgerAccountMethods { const balanceRecord = Object.fromEntries([ ...params.ids.map(id => [id, { amount: 0, fees: 0 }]), ...balanceArray.map(balance => [ - balance.ledgerAccountId, + balance.ledgerAccountId, { amount: balance._sum.funds ?? 0, - fees: balance._sum.fees ?? 0 + fees: balance._sum.fees ?? 0 } ]) ]) @@ -154,22 +154,22 @@ export namespace LedgerAccountMethods { /** * 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + export const calculateBalance = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ id: z.number(), atTransactionId: z.number().optional(), }), method: async ({ prisma, session, params }) => { - const balances = await calculateBalances.client(prisma).execute({ + const balances = await calculateBalances({ params: { ids: [params.id], atTransactionId: params.atTransactionId, diff --git a/src/services/ledger/ledgerOperations/actions.ts b/src/services/ledger/ledgerOperations/actions.ts index 924d669e0..a0ff5221a 100644 --- a/src/services/ledger/ledgerOperations/actions.ts +++ b/src/services/ledger/ledgerOperations/actions.ts @@ -1,7 +1,7 @@ 'use server' -import { action } from "@/actions/action"; -import { LedgerOperationMethods } from "./methods"; +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 index b8dcc8949..03f1b74c9 100644 --- a/src/services/ledger/ledgerOperations/methods.ts +++ b/src/services/ledger/ledgerOperations/methods.ts @@ -1,9 +1,9 @@ -import { RequireNothing } from "@/auth/auther/RequireNothing" -import { ServiceMethod } from "@/services/ServiceMethod" -import { LedgerTransactionMethods } from "../ledgerTransactions/methods" -import { PaymentMethods } from "../payments/methods" -import { z } from "zod" -import { PaymentProvider } from "@prisma/client" +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 @@ -13,18 +13,18 @@ import { PaymentProvider } from "@prisma/client" 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), + export const createDeposit = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), opensTransaction: true, paramsSchema: z.object({ ledgerAccountId: z.number(), - provider: z.nativeEnum(PaymentProvider), + provider: z.nativeEnum(PaymentProvider), funds: z.coerce.number().positive(), }), method: async ({ prisma, params }) => { @@ -33,7 +33,7 @@ export namespace LedgerOperationMethods { params: { provider: params.provider, funds: params.funds, - descriptionLong: 'Innskudd', + descriptionLong: 'Innskudd', descriptionShort: 'Innskudd', }, }) @@ -64,45 +64,43 @@ export namespace LedgerOperationMethods { /** * 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), + 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."), + }).refine((data) => data.funds || data.fees, 'Både beløp og avgifter kan ikke være 0 samtidig.'), opensTransaction: true, - method: async ({ prisma, params }) => { - return prisma.$transaction(async tx => { - const payment = await PaymentMethods.create({ - params: { - provider: 'MANUAL', - descriptionLong: 'Utbetaling', - descriptionShort: 'Utbetaling', + 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, - }, - }) - - const transaction = await LedgerTransactionMethods.create({ - params: { - purpose: 'PAYOUT', - ledgerEntries: [{ - ledgerAccountId: params.ledgerAccountId, - funds: -params.funds, - }], - paymentId: payment.id, - }, - }) - - return transaction + }], + paymentId: payment.id, + }, }) - } + + return transaction + }) }) } diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/ledgerOperations/schemas.ts index e7f0ede55..de5115064 100644 --- a/src/services/ledger/ledgerOperations/schemas.ts +++ b/src/services/ledger/ledgerOperations/schemas.ts @@ -1,5 +1,5 @@ -import { PaymentProvider } from "@prisma/client"; -import { z } from "zod"; +import { PaymentProvider } from '@prisma/client' +import { z } from 'zod' export namespace LedgerOperationSchemas { export const createDepositSchema = z.object({ @@ -9,4 +9,4 @@ export namespace LedgerOperationSchemas { // 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."); -} \ No newline at end of file +} diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/Type.ts index 602d49910..e9e772a14 100644 --- a/src/services/ledger/ledgerTransactions/Type.ts +++ b/src/services/ledger/ledgerTransactions/Type.ts @@ -1,4 +1,4 @@ -import type { Prisma } from "@prisma/client"; +import type { Prisma } from '@prisma/client' export type ExpandedLedgerTransaction = Prisma.LedgerTransactionGetPayload<{ include: { diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/ledgerTransactions/calculateFees.ts index de48ed138..6b95098fb 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -1,24 +1,23 @@ -import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" +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; + if (entryAmount === 0 || totalAmount === 0) return 0 - let fees = Math.trunc(totalFees * entryAmount / totalAmount); + const fees = Math.trunc(totalFees * entryAmount / totalAmount) - // Clamp fees to have same sign as amount + // Clamp fees to have same sign as amount // and never exceed total fees. if (entryAmount > 0) { - return Math.min(Math.max(fees, 0), totalFees); - } else { - return Math.min(Math.max(fees, -totalFees), 0); + return Math.min(Math.max(fees, 0), totalFees) } + return Math.min(Math.max(fees, -totalFees), 0) } /** @@ -28,7 +27,7 @@ export function feesFormula(entryAmount: number, totalAmount: number, totalFees: export function calculateDebitFees(ledgerEntries: { funds: number, ledgerAccountId: number }[], balances: BalanceRecord) { const debitLedgerEntries = ledgerEntries.filter(entry => entry.funds < 0) - return Object.fromEntries(debitLedgerEntries.map(entry => { + return Object.fromEntries(debitLedgerEntries.map(entry => { const balance = balances[entry.ledgerAccountId] if (!balance) throw Error(`Balance for ledger account nr. ${entry.ledgerAccountId} not provided.`) @@ -63,16 +62,16 @@ export function calculateCreditFees( ...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 + // that the sum of all fees ends up exactly // equal to `totalFees`. totalFunds -= entry.funds totalFees -= fees return [entry.ledgerAccountId, fees] })) -} \ No newline at end of file +} diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts index 063913b54..530df6581 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -1,18 +1,24 @@ -import { LedgerTransactionState, PaymentState } from "@prisma/client" -import { ExpandedLedgerTransaction } from "./Type" -import { BalanceRecord } from "@/services/ledger/ledgerAccount/Types" +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 | undefined +type LedgerTransactionRule = ( + transaction: ExpandedLedgerTransaction, + balances: BalanceRecord, +) => LedgerTransactionTransition | null /** * Determines the state of a given transaction. */ -export async function determineTransactionState(transaction: ExpandedLedgerTransaction, balances: BalanceRecord): Promise { +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. @@ -29,7 +35,7 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans for (const rule of rules) { const state = rule(transaction, balances) - + if (state) return state } @@ -40,97 +46,129 @@ export async function determineTransactionState(transaction: ExpandedLedgerTrans * A transaction in a terminal state (SUCCEEDED, FAILED or CANCELED) * can never change state. */ -function noTerminalState({ state }: ExpandedLedgerTransaction): LedgerTransactionTransition | undefined { +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 | undefined { +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 | undefined { +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 | undefined { +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 | undefined { +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.") + 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 | undefined { +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 | undefined { - const hasNullFees = +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 | undefined { +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 index 60a25e6a6..a613f5f13 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -1,21 +1,21 @@ +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 { ServiceMethod } from '@/services/ServiceMethod' +import { ServerError } from '@/services/error' +import { serviceMethod } from '@/services/serviceMethod' import { LedgerTransactionPurpose } from '@prisma/client' -import type { Prisma } from '@prisma/client' import { z } from 'zod' -import { LedgerAccountMethods } from '../ledgerAccount/methods' -import { ServerError } from '@/services/error' -import { calculateCreditFees, calculateDebitFees } from './calculateFees' -import { determineTransactionState } from './determineTransactionState' +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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), + export const read = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ id: z.number(), }), @@ -42,8 +42,8 @@ export namespace LedgerTransactionMethods { /** * Read several ledger transactions including its ledger entries, payment and manual transfer (if any). */ - export const readPage = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), + export const readPage = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: readPageInputSchemaObject( z.number(), z.object({ @@ -73,90 +73,18 @@ export namespace LedgerTransactionMethods { }) }) - /** - * 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({ - auther: () => 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, session, 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.client(prisma).execute({ - params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, - session: null, - }) - - // 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.client(prisma).execute({ - params: { - id, - }, - session, - }) - - if (transaction.state === 'FAILED') { - // TODO: Better error message. - throw new ServerError('BAD PARAMETERS', transaction.reason ?? 'Transaksjonen feilet av ukjent årsak.') - } - - return transaction - } - }) - /** * Tries to advance the transactions state to a terminal state. * Also, updates the fees if possible. */ - export const advance = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), + export const advance = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ id: z.number(), }), - method: async ({ session, prisma, params}) => { - let transaction = await read.client(prisma).execute({ + method: async ({ prisma, params }) => { + let transaction = await read({ params: { id: params.id }, - session, }) const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) @@ -184,17 +112,16 @@ export namespace LedgerTransactionMethods { }, }) - transaction.ledgerEntries.forEach( - entry => entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees - ) + transaction.ledgerEntries.forEach(entry => { + entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees + }) } - const balances = await LedgerAccountMethods.calculateBalances.client(prisma).execute({ + const balances = await LedgerAccountMethods.calculateBalances({ params: { ids: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), atTransactionId: transaction.id, }, - session: null, }) const transition = await determineTransactionState(transaction, balances) @@ -208,13 +135,81 @@ export namespace LedgerTransactionMethods { }, data: transition, }) - - transaction = await read.client(prisma).execute({ + + transaction = await read({ params: { id: params.id }, - session, }) 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 index ab2b5583e..130da10be 100644 --- a/src/services/ledger/payments/Types.ts +++ b/src/services/ledger/payments/Types.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import type { Prisma } from '@prisma/client' export type ExpandedPayment = Prisma.PaymentGetPayload<{ include: { diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/methods.ts index 9d289d07b..a0c745fdd 100644 --- a/src/services/ledger/payments/methods.ts +++ b/src/services/ledger/payments/methods.ts @@ -1,9 +1,9 @@ -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" +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 { @@ -12,8 +12,8 @@ export namespace PaymentMethods { * 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + export const create = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.intersection( z.object({ funds: z.number(), @@ -61,15 +61,15 @@ export namespace PaymentMethods { /** * 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({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + 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 + // 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 }) => { diff --git a/src/services/ledger/payments/stripeWebhookCallback.ts b/src/services/ledger/payments/stripeWebhookCallback.ts index 771162489..95d07fa2c 100644 --- a/src/services/ledger/payments/stripeWebhookCallback.ts +++ b/src/services/ledger/payments/stripeWebhookCallback.ts @@ -1,28 +1,34 @@ -import type Stripe from "stripe" -import prisma from "@/prisma" -import logger from "@/lib/logger" -import { PaymentState } from "@prisma/client" -import { stripe } from "@/lib/stripe" +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 + * + * @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': ${latest_charge}`) + 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': ${balance_transaction}`) + logger.error( + 'Stripe payment intent event was missing balance transaction object.' + + `'balance_transaction': ${balanceTransaction}` + ) return null } @@ -40,21 +46,23 @@ const EVENT_TYPE_TO_STATE: Partial> = * 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. - * + * 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]; + const paymentState = EVENT_TYPE_TO_STATE[event.type] if (!paymentState) { logger.error('Received unsupported Stripe event type.') @@ -72,7 +80,7 @@ export async function stripeWebhookCallback(event: Stripe.Event): Promise RequireNothing.staticFields({}).dynamicFields({}), - method: async ({ prisma, session }) => { - return { - inTransaction: "$transaction" in prisma, - apiKeyId: session.apiKeyId, - } - } +const returnContextInfo = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async ({ prisma, session }) => ({ + inTransaction: '$transaction' in prisma, + apiKeyId: session.apiKeyId, + }) }) -const callReturnContextInfo = ServiceMethod({ - auther: () => RequireNothing.staticFields({}).dynamicFields({}), - method: async () => { - return returnContextInfo({}) - } +const callReturnContextInfo = serviceMethod({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), + method: async () => returnContextInfo({}) }) describe('context', () => { @@ -28,16 +26,16 @@ describe('context', () => { permissions: [], }) const emptySession = Session.empty() - - const contexts: Context[] = [ - { session: emptySession, prisma }, - { session: apiKeySession, prisma }, - { session: null, prisma }, + + 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, + inTransaction: '$transaction' in context.prisma, apiKeyId: context.session?.apiKeyId, } @@ -46,4 +44,4 @@ describe('context', () => { expect(res).toMatchObject(expected) } }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/calculateFees.test.ts b/tests/services/ledger/calculateFees.test.ts index fa88fbcc0..9f45d4527 100644 --- a/tests/services/ledger/calculateFees.test.ts +++ b/tests/services/ledger/calculateFees.test.ts @@ -1,36 +1,36 @@ -import { feesFormula } from "@/services/ledger/ledgerTransactions/calculateFees"; -import { describe, expect, test } from "@jest/globals"; +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], + [{ 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], + [{ entryAmount: 33, totalAmount: 100, totalFees: 10 }, 3], + [{ entryAmount: 25, totalAmount: 100, totalFees: 10 }, 2], // Zero amount - [{ entryAmount: 0, totalAmount: 100, totalFees: 10 }, 0], + [{ entryAmount: 0, totalAmount: 100, totalFees: 10 }, 0], // Insufficient balance - [{ entryAmount: 100, totalAmount: 0, totalFees: 10 }, 0], - [{ entryAmount: 0, totalAmount: 0, totalFees: 10 }, 0], + [{ 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], + [{ 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], + [{ 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. @@ -43,4 +43,4 @@ describe('ledger entry fees calculation', () => { const fees = feesFormula(-entryAmount, totalAmount, totalFees) expect(fees).toBeCloseTo(-expectedFees) }) -}) \ No newline at end of file +}) diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index e156b1588..4f0ec4c0f 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -2,24 +2,25 @@ 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 { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' 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', () => { - let testAccountIds: number[] = [] + 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', + email: `${username}@example.com`, firstname: 'Test', lastname: 'User', username, @@ -42,7 +43,7 @@ describe('ledger transactions', () => { await prisma.ledgerEntry.deleteMany({}) await prisma.ledgerTransaction.deleteMany({}) }) - + describe('external transactions', () => { }) @@ -50,25 +51,27 @@ describe('ledger transactions', () => { describe('internal transactions', () => { beforeEach(async () => { await allSettledOrThrow(testAccountIds.map(async accountId => { - const manualPayment = await PaymentMethods.create({ - params: { - funds: INITIAL_BALANCE.amount, + const manualPayment = await PaymentMethods.create({ + params: { + funds: INITIAL_BALANCE.amount, + provider: 'MANUAL', + details: { fees: INITIAL_BALANCE.fees, - provider: 'MANUAL', }, - }) - - await LedgerTransactionMethods.create({ - params: { - purpose: 'DEPOSIT', - ledgerEntries: [{ - ledgerAccountId: accountId, - funds: INITIAL_BALANCE.amount, - }], - paymentId: manualPayment.id, - } - }) + }, }) + + await LedgerTransactionMethods.create({ + params: { + purpose: 'DEPOSIT', + ledgerEntries: [{ + ledgerAccountId: accountId, + funds: INITIAL_BALANCE.amount, + }], + paymentId: manualPayment.id, + } + }) + }) ) }) diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts index e9a7111cc..e4a69394b 100644 --- a/tests/services/ledger/payments.test.ts +++ b/tests/services/ledger/payments.test.ts @@ -1,6 +1,5 @@ -import { describe, test, expect, jest, beforeEach, beforeAll } from '@jest/globals' -// TODO: +// TODO: // jest.mock('@/lib/stripe', () => ({ // stripe: { // paymentIntent: { @@ -12,10 +11,11 @@ import { describe, test, expect, jest, beforeEach, beforeAll } from '@jest/globa import { Smorekopp } from '@/services/error' import { PaymentMethods } from '@/services/ledger/payments/methods' -import prisma from '@/prisma' -import { PaymentProvider } from '@prisma/client' import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' -import Stripe from 'stripe' +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, @@ -38,20 +38,18 @@ describe.skip('payments', () => { }) test.each([PaymentProvider.MANUAL, PaymentProvider.STRIPE])('payment flow', async (provider) => { - let payment = await PaymentMethods.create.newClient().execute({ + let payment = await PaymentMethods.create({ params: { ...TEST_PAYMENT_DEFAULTS, provider, }, - session: null, }) if (payment.state === 'PENDING') { - payment = await PaymentMethods.initiate.newClient().execute({ + payment = await PaymentMethods.initiate({ params: { paymentId: payment.id, }, - session: null, }) stripeWebhookCallback({ @@ -75,7 +73,7 @@ describe.skip('payments', () => { }) test('initiate manual payment', async () => { - const payment = await PaymentMethods.create.newClient().execute({ + const payment = await PaymentMethods.create({ params: { ledgerAccountId: 0, amount: 100, // 1 kr @@ -83,10 +81,9 @@ describe.skip('payments', () => { description: 'Test betaling', descriptor: 'Test betaling', }, - session: null, }) - expect(PaymentMethods.initiate.newClient().execute({ params: { paymentId: payment.id }, session: null })) + expect(PaymentMethods.initiate({ params: { paymentId: payment.id } })) .rejects.toThrow(new Smorekopp('BAD DATA')) }) }) diff --git a/tests/services/permissions.test.ts b/tests/services/permissions.test.ts deleted file mode 100644 index 771650ba1..000000000 --- a/tests/services/permissions.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Session } from '@/auth/Session' -import { Smorekopp } from '@/services/error' -import prisma from '@/prisma' -import { ApiKeyMethods } from '@/services/api-keys/methods' -import { afterEach, beforeAll, describe, expect, test } from '@jest/globals' - -describe('permissions', () => { - test('default', async () => { - // TODO - }) - - test('group', async () => { - // TODO - }) - - test('group and default', async() => { - - }) -}) diff --git a/tests/utils.ts b/tests/utils.ts index be46fb4b3..74894e617 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,7 @@ /** * 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. * @@ -24,4 +24,4 @@ export async function allSettledOrThrow(promises: Promise[]): Promise const fulfilled = results.filter(result => result.status === 'fulfilled') return fulfilled.map(result => result.value) -} \ No newline at end of file +} From 29738ca62906841e4346c1bfb54936e3c2eb3a6c Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Sun, 21 Sep 2025 23:55:02 +0200 Subject: [PATCH 35/62] refactor: reorganize UI components (no work) --- .../LedgerAccountBalance.module.scss | 0 .../{ => Account}/LedgerAccountBalance.tsx | 8 +- .../Account/LedgerAccountOverviewCard.tsx | 17 ++++ .../LedgerAccountPaymentMethodsCard.tsx | 24 +++++ .../Account/LedgerAccountTransactionsCard.tsx | 23 +++++ .../Ledger/Modals}/BankCardModal.tsx | 0 .../{ => Modals}/CheckoutModal.module.scss | 0 .../CheckoutModal.tsx} | 10 +-- .../{ => Modals}/DepositModal.module.scss | 0 .../Ledger/{ => Modals}/DepositModal.tsx | 16 ++-- .../{ => Modals}/ManualPaymentIntput.tsx | 0 .../{ => Modals}/PayoutModal.module.scss | 0 .../Ledger/{ => Modals}/PayoutModal.tsx | 10 +-- .../Ledger/TransactionList.module.scss | 10 --- .../_components/Ledger/TransactionList.tsx | 45 ---------- .../TransactionList/TransactionList.tsx | 25 ------ .../Ledger/TransactionList/TransactionRow.tsx | 18 ---- .../Ledger/TransactionRow.module.scss | 12 --- src/app/_components/Ledger/TransactionRow.tsx | 20 ----- .../LedgerTransactionList.module.scss | 0 .../Transactions/LedgerTransactionList.tsx | 22 +++++ .../LedgerTransactionRow.module.scss} | 0 .../Transactions/LedgerTransactionRow.tsx | 21 +++++ src/app/_components/NavBar/UserNavigation.tsx | 4 +- src/app/_components/UI/Card.module.scss | 12 +++ .../account => _components/UI}/Card.tsx | 0 src/app/admin/SlideSidebar.tsx | 15 +++- src/app/admin/accounts/[accountId]/page.tsx | 3 + .../[accountId]/transactions/page.tsx | 3 + src/app/admin/accounts/page.tsx | 2 +- .../(user-admin)/account/Card.module.scss | 18 ---- .../(user-admin)/account/CheckOutModal.tsx | 47 ---------- .../(user-admin)/account/DepositModal.tsx | 38 -------- .../(user-admin)/account/PayoutModal.tsx | 38 -------- .../[username]/(user-admin)/account/page.tsx | 89 ++----------------- .../account/transactions/page.tsx | 12 +-- .../paging/LedgerTranasctionPaging.tsx | 23 +++++ src/contexts/paging/TranasctionPaging.tsx | 24 ----- .../ledger/ledgerTransactions/actions.ts | 4 + .../ledger/ledgerTransactions/methods.ts | 7 +- 40 files changed, 206 insertions(+), 414 deletions(-) rename src/app/_components/Ledger/{ => Account}/LedgerAccountBalance.module.scss (100%) rename src/app/_components/Ledger/{ => Account}/LedgerAccountBalance.tsx (70%) create mode 100644 src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx create mode 100644 src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx create mode 100644 src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx rename src/app/{users/[username]/(user-admin)/account => _components/Ledger/Modals}/BankCardModal.tsx (100%) rename src/app/_components/Ledger/{ => Modals}/CheckoutModal.module.scss (100%) rename src/app/_components/Ledger/{CheckoutModalOld.tsx => Modals/CheckoutModal.tsx} (96%) rename src/app/_components/Ledger/{ => Modals}/DepositModal.module.scss (100%) rename src/app/_components/Ledger/{ => Modals}/DepositModal.tsx (91%) rename src/app/_components/Ledger/{ => Modals}/ManualPaymentIntput.tsx (100%) rename src/app/_components/Ledger/{ => Modals}/PayoutModal.module.scss (100%) rename src/app/_components/Ledger/{ => Modals}/PayoutModal.tsx (88%) delete mode 100644 src/app/_components/Ledger/TransactionList.module.scss delete mode 100644 src/app/_components/Ledger/TransactionList.tsx delete mode 100644 src/app/_components/Ledger/TransactionList/TransactionList.tsx delete mode 100644 src/app/_components/Ledger/TransactionList/TransactionRow.tsx delete mode 100644 src/app/_components/Ledger/TransactionRow.module.scss delete mode 100644 src/app/_components/Ledger/TransactionRow.tsx create mode 100644 src/app/_components/Ledger/Transactions/LedgerTransactionList.module.scss create mode 100644 src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx rename src/app/_components/Ledger/{TransactionList/TransactionRow.module.scss => Transactions/LedgerTransactionRow.module.scss} (100%) create mode 100644 src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx create mode 100644 src/app/_components/UI/Card.module.scss rename src/app/{users/[username]/(user-admin)/account => _components/UI}/Card.tsx (100%) create mode 100644 src/app/admin/accounts/[accountId]/page.tsx create mode 100644 src/app/admin/accounts/[accountId]/transactions/page.tsx delete mode 100644 src/app/users/[username]/(user-admin)/account/Card.module.scss delete mode 100644 src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx delete mode 100644 src/app/users/[username]/(user-admin)/account/DepositModal.tsx delete mode 100644 src/app/users/[username]/(user-admin)/account/PayoutModal.tsx create mode 100644 src/contexts/paging/LedgerTranasctionPaging.tsx delete mode 100644 src/contexts/paging/TranasctionPaging.tsx create mode 100644 src/services/ledger/ledgerTransactions/actions.ts diff --git a/src/app/_components/Ledger/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/Account/LedgerAccountBalance.module.scss similarity index 100% rename from src/app/_components/Ledger/LedgerAccountBalance.module.scss rename to src/app/_components/Ledger/Account/LedgerAccountBalance.module.scss diff --git a/src/app/_components/Ledger/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx similarity index 70% rename from src/app/_components/Ledger/LedgerAccountBalance.tsx rename to src/app/_components/Ledger/Account/LedgerAccountBalance.tsx index fc72cfc17..1aa1f43cb 100644 --- a/src/app/_components/Ledger/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx @@ -1,15 +1,15 @@ import styles from './LedgerAccountBalance.module.scss' -// import { calculateLedgerAccountBalance } from '@/actions/ledger/ledgerAccount' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { displayAmount } from '@/lib/currency/convert' +import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' type Props = { - accountId: number, + ledgerAccountId: number, showFees?: boolean, } -export default async function LedgerAccountBalance({ accountId, showFees }: Props) { - const balance = { amount: 100, fees: 2 } // unwrapActionReturn(await calculateLedgerAccountBalance({ id: accountId })) +export default async function LedgerAccountBalance({ ledgerAccountId: accountId, showFees }: Props) { + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: accountId})) return
diff --git a/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx new file mode 100644 index 000000000..78b0bc507 --- /dev/null +++ b/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx @@ -0,0 +1,17 @@ +import Card from "@/components/UI/Card"; +import DepositModal from "@/components/Ledger/Modals/DepositModal"; +import PayoutModal from "@/components/Ledger/Modals/PayoutModal"; +import LedgerAccountBalance from "./LedgerAccountBalance"; + +type Props = { + ledgerAccountId: number, +} + +export default function LedgerAccountOverview({ ledgerAccountId }: Props) { + return +

Kontooversikt

+ + + +
+} \ No newline at end of file diff --git a/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx new file mode 100644 index 000000000..e7eb8fc35 --- /dev/null +++ b/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx @@ -0,0 +1,24 @@ +import Card from "@/components/UI/Card"; +import BankCardModal from "../Modals/BankCardModal"; +import Link from "next/link"; + +type Props = { + userId: number, +} + +export default function LedgerAccountPaymentMethods({ userId }: Props) { + return +

Betalingsalternativer

+

Bankkort

+

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

+ +

NTNU-kort

+

+ For å benytte Kioleskabet på Lophtet må et NTNU-kort være tilknyttet brukeren din. +

+ Gå til siden for kortregistrering. +
+} diff --git a/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx new file mode 100644 index 000000000..9060b4c10 --- /dev/null +++ b/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx @@ -0,0 +1,23 @@ +import Card from "@/components/UI/Card"; +import Link from "next/link"; + +type Props = { + transactionsHref?: string, +} + +export default function LedgerAccountTransactionSummary({ transactionsHref }: Props) { + return +

Transaksjoner

+
Tilgjengelig Saldo
01.01.19700 Kluengende Meunt0 Kluengende Meunt Innskudd - Ingen
+ + + + + + + + +
En transaksjon
En annen transaksjon
+ { transactionsHref && Se alle transaksjoner -> } + +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx similarity index 100% rename from src/app/users/[username]/(user-admin)/account/BankCardModal.tsx rename to src/app/_components/Ledger/Modals/BankCardModal.tsx diff --git a/src/app/_components/Ledger/CheckoutModal.module.scss b/src/app/_components/Ledger/Modals/CheckoutModal.module.scss similarity index 100% rename from src/app/_components/Ledger/CheckoutModal.module.scss rename to src/app/_components/Ledger/Modals/CheckoutModal.module.scss diff --git a/src/app/_components/Ledger/CheckoutModalOld.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx similarity index 96% rename from src/app/_components/Ledger/CheckoutModalOld.tsx rename to src/app/_components/Ledger/Modals/CheckoutModal.tsx index c64f4a0c8..e1cbfd266 100644 --- a/src/app/_components/Ledger/CheckoutModalOld.tsx +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -6,12 +6,12 @@ import Button from '@/components/UI/Button' 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 '@/actions/Types' -import { createActionError } from '@/actions/error' +import type { StripePaymentRef } from '../../Stripe/StripePayment' +import type { ActionReturn } from '@/services/actionTypes' +import { createActionError } from '@/services/actionError' -const StripeProvider = lazy(() => import('../Stripe/StripeProvider')) -const StripePayment = lazy(() => import('../Stripe/StripePayment')) +const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) +const StripePayment = lazy(() => import('../../Stripe/StripePayment')) const defaultPaymentProvider: PaymentProvider = 'STRIPE' diff --git a/src/app/_components/Ledger/DepositModal.module.scss b/src/app/_components/Ledger/Modals/DepositModal.module.scss similarity index 100% rename from src/app/_components/Ledger/DepositModal.module.scss rename to src/app/_components/Ledger/Modals/DepositModal.module.scss diff --git a/src/app/_components/Ledger/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx similarity index 91% rename from src/app/_components/Ledger/DepositModal.tsx rename to src/app/_components/Ledger/Modals/DepositModal.tsx index 5babd66c1..9c9b82916 100644 --- a/src/app/_components/Ledger/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -1,21 +1,21 @@ 'use client' import styles from './DepositModal.module.scss' -import Form from '../Form/Form' -import PopUp from '../PopUp/PopUp' -import NumberInput from '../UI/NumberInput' -import Button from '../UI/Button' +import Form from '../../Form/Form' +import PopUp from '../../PopUp/PopUp' +import NumberInput from '../../UI/NumberInput' +import Button from '../../UI/Button' import { createDepositAction } from '@/services/ledger/ledgerOperations/actions' import { convertAmount, displayAmount } from '@/lib/currency/convert' import { lazy, useRef, useState } from 'react' import type { PaymentProvider } from '@prisma/client' import type { ExpandedPayment } from '@/services/ledger/payments/Types' -import type { StripePaymentRef } from '../Stripe/StripePayment' -import { createActionError } from '@/actions/error' +import type { StripePaymentRef } from '../../Stripe/StripePayment' +import { createActionError } from '@/services/actionError' // Avoid loading the Stripe components until they are needed -const StripePayment = lazy(() => import('../Stripe/StripePayment')) -const StripeProvider = lazy(() => import('../Stripe/StripeProvider')) +const StripePayment = lazy(() => import('../../Stripe/StripePayment')) +const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) const minFunds = 50_00 diff --git a/src/app/_components/Ledger/ManualPaymentIntput.tsx b/src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx similarity index 100% rename from src/app/_components/Ledger/ManualPaymentIntput.tsx rename to src/app/_components/Ledger/Modals/ManualPaymentIntput.tsx diff --git a/src/app/_components/Ledger/PayoutModal.module.scss b/src/app/_components/Ledger/Modals/PayoutModal.module.scss similarity index 100% rename from src/app/_components/Ledger/PayoutModal.module.scss rename to src/app/_components/Ledger/Modals/PayoutModal.module.scss diff --git a/src/app/_components/Ledger/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx similarity index 88% rename from src/app/_components/Ledger/PayoutModal.tsx rename to src/app/_components/Ledger/Modals/PayoutModal.tsx index 635c0808b..fe04b6a10 100644 --- a/src/app/_components/Ledger/PayoutModal.tsx +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -1,14 +1,14 @@ '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 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 { useState } from 'react' -import { bindParams } from '@/actions/bind' +import { bindParams } from '@/services/actionBind' type Props = { ledgerAccountId: number, diff --git a/src/app/_components/Ledger/TransactionList.module.scss b/src/app/_components/Ledger/TransactionList.module.scss deleted file mode 100644 index 427338a37..000000000 --- a/src/app/_components/Ledger/TransactionList.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -@use '@/styles/ohma'; - -.DotList { - @include ohma.table(); - margin-top: 0; - .inactive { - text-decoration: line-through; - color: ohma.$colors-red; - } -} \ No newline at end of file diff --git a/src/app/_components/Ledger/TransactionList.tsx b/src/app/_components/Ledger/TransactionList.tsx deleted file mode 100644 index ed69dc63b..000000000 --- a/src/app/_components/Ledger/TransactionList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import styles from './TransactionList.module.scss' -import TransactionRow from './TransactionRow' -import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' -import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' - -type Props = { - accountId: number, - // TODO: showFees?: boolean, -} - -export default function TransactionList({ accountId }: Props) { - return - - - - - - - - - - - - - - - - - - - - - {/* The EndlessScroll component will render the rows */} - -
DatoBeløpTypeBeskrivelseBetalingsmåteSaldoendring
01.01.19700 Kluengende MeuntInnskudd-Ingen-
- - } - /> -
-} diff --git a/src/app/_components/Ledger/TransactionList/TransactionList.tsx b/src/app/_components/Ledger/TransactionList/TransactionList.tsx deleted file mode 100644 index c34fb5056..000000000 --- a/src/app/_components/Ledger/TransactionList/TransactionList.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'use client' - -// import TransactionRow from './TransactionRow' -// import TransactionPagingProvider, { TransactionPagingContext } from '@/contexts/paging/TranasctionPaging' -// import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' - -type Props = { - accountId: number, - // TODO: showFees?: boolean, -} - -export default function TransactionList({ accountId }: Props) { - return
- Transaction list for account {accountId} -
- - // return - // - // } - // /> - // -} diff --git a/src/app/_components/Ledger/TransactionList/TransactionRow.tsx b/src/app/_components/Ledger/TransactionList/TransactionRow.tsx deleted file mode 100644 index 12414bb69..000000000 --- a/src/app/_components/Ledger/TransactionList/TransactionRow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import styles from './TransactionRow.module.scss' -import { displayAmount } from '@/lib/currency/convert' -import type { Transaction } from '@prisma/client' - -type Props = { - transaction: Transaction, - showFees?: boolean, -} - -export default function TransactionRow({ transaction, showFees }: Props) { - return -

{transaction.createdAt.toLocaleString()}

-

{displayAmount((transaction.amount))}

- {showFees &&

{transaction.fee ? displayAmount(transaction.fee) : '-'}

} -

{transaction.type}

-

{transaction.status}

-
-} diff --git a/src/app/_components/Ledger/TransactionRow.module.scss b/src/app/_components/Ledger/TransactionRow.module.scss deleted file mode 100644 index b5e3075d6..000000000 --- a/src/app/_components/Ledger/TransactionRow.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@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/TransactionRow.tsx b/src/app/_components/Ledger/TransactionRow.tsx deleted file mode 100644 index 143066246..000000000 --- a/src/app/_components/Ledger/TransactionRow.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// import styles from './TransactionRow.module.scss' -import { displayAmount } from '@/lib/currency/convert' -import type { Prisma } from '@prisma/client' - -type Props = { - transaction: Prisma.TransactionGetPayload<{ include: { payment: true, transfer: true } }>, - showFees?: boolean, -} - -export default function TransactionRow({ transaction, showFees }: Props) { - const totalAmount = Number(transaction.transfer?.amount) + Number(transaction.payment?.amount) - const totalFees = Number(transaction.transfer?.fees) + Number(transaction.payment?.fees) - return - {transaction.createdAt.toLocaleString()} - {transaction.purpose} - {displayAmount(totalAmount)} - {/* {showFees &&

{displayAmount(totalFees)}

} */} - {transaction.status} - -} 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..f1cf8d12c --- /dev/null +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -0,0 +1,22 @@ +'use client' + +import styles from './LedgerTransactionList.module.scss' +import EndlessScroll from "@/components/PagingWrappers/EndlessScroll" +import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from "@/contexts/paging/LedgerTranasctionPaging" +import LedgerTransactionRow from "./LedgerTransactionRow" + +type Props = { + accountId: number, + // TODO: showFees?: boolean, +} + +export default function TransactionList({ accountId }: Props) { + return + + } + /> + +} diff --git a/src/app/_components/Ledger/TransactionList/TransactionRow.module.scss b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss similarity index 100% rename from src/app/_components/Ledger/TransactionList/TransactionRow.module.scss rename to src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss 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 1b1dfe74d..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' @@ -55,7 +55,7 @@ export default function UserNavigation({ profile }: PropTypes) {

OmegaId

- +

Konto

diff --git a/src/app/_components/UI/Card.module.scss b/src/app/_components/UI/Card.module.scss new file mode 100644 index 000000000..761d0c18c --- /dev/null +++ b/src/app/_components/UI/Card.module.scss @@ -0,0 +1,12 @@ +@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); + margin: 5*ohma.$gap 0; + overflow: hidden; + background-color: $background; +} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/Card.tsx b/src/app/_components/UI/Card.tsx similarity index 100% rename from src/app/users/[username]/(user-admin)/account/Card.tsx rename to src/app/_components/UI/Card.tsx 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..9b09e0421 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -0,0 +1,3 @@ +export default async function LedgerAccount() { + return 'En konto' +} \ No newline at end of file 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..e690a0813 --- /dev/null +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -0,0 +1,3 @@ +export default async function LedgerAccountTransactions() { + return 'Transaksjoner' +} \ No newline at end of file diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx index 0dc058f2f..c1342bbca 100644 --- a/src/app/admin/accounts/page.tsx +++ b/src/app/admin/accounts/page.tsx @@ -1,3 +1,3 @@ -export default function Accounts() { +export default async function LedgerAccountList() { return 'Yo' } diff --git a/src/app/users/[username]/(user-admin)/account/Card.module.scss b/src/app/users/[username]/(user-admin)/account/Card.module.scss deleted file mode 100644 index 38fc00b2a..000000000 --- a/src/app/users/[username]/(user-admin)/account/Card.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -@use "@/styles/ohma"; - -$background: ohma.$colors-white; - -.Card { - // min-width: 200px; - // max-width: 300px; - 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); - margin: 5*ohma.$gap 0; - // text-decoration: none; - // display: block; - // height: $height; - overflow: hidden; - background-color: $background; - // margin: calc(2 * ohma.$gap);; -} \ No newline at end of file diff --git a/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx b/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx deleted file mode 100644 index 17374b3c3..000000000 --- a/src/app/users/[username]/(user-admin)/account/CheckOutModal.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client' - -import PopUp from '@/app/_components/PopUp/PopUp' -import Button from '@/app/_components/UI/Button' -import Form from '@/components/Form/Form' -import type { PaymentProvider } from '@prisma/client' -import { createUserAction } from '@/actions/users/create' - -type PropTypes = { - title: string, - children?: React.ReactNode, -} - -const defaultPaymentProvider: PaymentProvider = 'STRIPE' - -const paymentProviders: Record = { - STRIPE: 'Stripe', - MANUAL: 'Manuell betaling', -} - -export default function CheckoutModal({ title, children }: PropTypes) { - return ( - } - > -

1 kg. Maragin

-

1 kg. Farin

-

1 ts. Pepper

- {children} -
-
- Betal med... - - {Object.entries(paymentProviders).map(([provider, name]) => ( - - ))} -
-
- - -
- ) -} diff --git a/src/app/users/[username]/(user-admin)/account/DepositModal.tsx b/src/app/users/[username]/(user-admin)/account/DepositModal.tsx deleted file mode 100644 index f67c19366..000000000 --- a/src/app/users/[username]/(user-admin)/account/DepositModal.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client' - -import Form from '@/app/_components/Form/Form' -import PopUp from '@/app/_components/PopUp/PopUp' -import Button from '@/app/_components/UI/Button' -import Checkbox from '@/app/_components/UI/Checkbox' -import NumberInput from '@/app/_components/UI/NumberInput' -import TextInput from '@/app/_components/UI/TextInput' -import { currencySymbol } from '@/lib/currency/config' -import { displayAmount } from '@/lib/currency/convert' - -type PropTypes = { - accountId: number, - paymentAmount?: number, - accountNumber?: string, -} - -export default function DepositModal({ accountId }: PropTypes) { - return ( - } - > -

Sett inn {currencySymbol}

- {/*
({ success: true, data: { accountId } })} - > */} - - {/* */} -

Betal med...

- - - -
- ) -} diff --git a/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx b/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx deleted file mode 100644 index 7ced601ca..000000000 --- a/src/app/users/[username]/(user-admin)/account/PayoutModal.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client' - -import Form from '@/app/_components/Form/Form' -import PopUp from '@/app/_components/PopUp/PopUp' -import Button from '@/app/_components/UI/Button' -import Checkbox from '@/app/_components/UI/Checkbox' -import NumberInput from '@/app/_components/UI/NumberInput' -import TextInput from '@/app/_components/UI/TextInput' -import { currencySymbol } from '@/lib/currency/config' -import { displayAmount } from '@/lib/currency/convert' - -type PropTypes = { - accountId: number, - paymentAmount?: number, - accountNumber?: string, -} - -export default function PayoutModal({ accountId, paymentAmount, accountNumber }: PropTypes) { - return ( - } - > -

Registrer utbetaling

- {paymentAmount &&

Utestående beløp: {displayAmount(paymentAmount)} {currencySymbol}

} -

Oppgitt kontonummer for utbetaling: {accountNumber ? {accountNumber} : Ingen}

-
({ success: true, data: { accountId } })} - > - - - - -
- ) -} diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 260f36782..d3c60e1fc 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,96 +1,23 @@ -// import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' -import Card from './Card' -import BankCardModal from './BankCardModal' -import LedgerAccountBalance from '@/app/_components/Ledger/LedgerAccountBalance' -import TextInput from '@/app/_components/UI/TextInput' +import LedgerAccountBalance from '@/components/Ledger/Account/LedgerAccountBalance' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/getUser' -import Button from '@/components/UI/Button' -import DepositModal from '@/components/Ledger/DepositModal' -import PayoutModal from '@/components/Ledger/PayoutModal' import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' -import Link from 'next/link' +import LedgerAccountOverview from '@/components/Ledger/Account/LedgerAccountOverviewCard' +import LedgerAccountPaymentMethods from '@/components/Ledger/Account/LedgerAccountPaymentMethodsCard' export default async function Account() { const session = await getUser({ userRequired: true, shouldRedirect: true, - }) // TODO: Replace + }) // TODO: Replace with whatever we agree should be the standard for getting user const account = { id: 1 } //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) - const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id })) + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: account.id })) return
- -

Kontooversikt

-

Saldo: 69 Kluengende Muente

-

Avgifter: 69 Kluengende Muente

- - - {/*

Konto

*/} - {/* */} -

- {/* */} - {/* */} -
- {/* } - > -

Sett inn muenter

- -
*/} - - {/* */} - {/*

Innskudd

-
*/} - {/*

Betaling

-
- - - -
-

Utbetaling

-
- - -
*/} - -

Betalingsalternativer

-

Bankkort

-

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

- -

NTNU-kort

-

- For å benytte Kioleskabet på Lophtet må et NTNU-kort være tilknyttet brukeren din. -

- Gå til siden for kortregistrering. -
- -

Transaksjoner

- - - - - - - - - -
En transaksjon
En annen transaksjon
-

Se alle transaksjoner ->

-
+ + +
} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index 27506ce74..49291f075 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,22 +1,12 @@ -// import { readLedgerAccount } from '@/actions/ledger/ledgerAccount' -import TransactionList from '@/app/_components/Ledger/TransactionList/TransactionList' +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' import { getUser } from '@/auth/getUser' -import { Session } from '@/auth/Session' export default async function Transactions() { - // const transactionPagingContext = useContext(TransactionPagingContext) - - // if (!transactionPagingContext) { - // throw new Error('fuck') - // } - const { user } = await getUser({ userRequired: true, shouldRedirect: true, }) - // const account = unwrapActionReturn(await readLedgerAccount({ userId: user.id })) - const account = { id: 1 } return diff --git a/src/contexts/paging/LedgerTranasctionPaging.tsx b/src/contexts/paging/LedgerTranasctionPaging.tsx new file mode 100644 index 000000000..1eaed27c1 --- /dev/null +++ b/src/contexts/paging/LedgerTranasctionPaging.tsx @@ -0,0 +1,23 @@ +'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 => ({ id: data[data.length - 1].id }), +}) + +export default LedgerTransactionPagingProvider diff --git a/src/contexts/paging/TranasctionPaging.tsx b/src/contexts/paging/TranasctionPaging.tsx deleted file mode 100644 index 9cfdf5020..000000000 --- a/src/contexts/paging/TranasctionPaging.tsx +++ /dev/null @@ -1,24 +0,0 @@ -'use client' - -import generatePagingProvider, { generatePagingContext } from './PagingGenerator' -// import { readTransactionsPage } from '@/actions/ledger/transactions/transactions' -import type { ReadPageInput } from '@/lib/paging/Types' -import type { LedgerTransaction } from '@prisma/client' - -// export type PageSizeTransactions = 10 -// const fetcher = async ( -// paging: ReadPageInput -// ) => readTransactionsPage({ paging }) - -// export const TransactionPagingContext = generatePagingContext< -// Transaction, -// { id: number }, -// PageSizeTransactions, -// { accountId: number } -// >() -// const TransactionPagingProvider = generatePagingProvider({ -// Context: TransactionPagingContext, -// fetcher, -// getCursorAfterFetch: data => (data.length ? { id: data[data.length - 1].id } : null), -// }) -// export default TransactionPagingProvider diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts new file mode 100644 index 000000000..574d72c77 --- /dev/null +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -0,0 +1,4 @@ +import { action } from '@/services/action'; +import { LedgerTransactionMethods } from './methods'; + +export const readLedgerTransactionPageAction = action(LedgerTransactionMethods.readPage) diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index a613f5f13..b3a685c7e 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -63,7 +63,12 @@ export namespace LedgerTransactionMethods { }, include: { ledgerEntries: true, - payment: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, + }, }, orderBy: { createdAt: 'desc', From 2a8c869124b2aaa0738fb5a8b8e0cd9062c4c81c Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 22 Sep 2025 01:00:38 +0200 Subject: [PATCH 36/62] feat: more boring ui cleanup --- .../Ledger/Account/LedgerAccountBalance.tsx | 2 +- .../Account/LedgerAccountOverviewCard.tsx | 14 ++-- .../LedgerAccountPaymentMethodsCard.tsx | 6 +- .../Account/LedgerAccountTransactionsCard.tsx | 6 +- .../Ledger/Modals/BankCardModal.tsx | 9 ++- .../Ledger/Modals/CheckoutModal.tsx | 2 +- .../Ledger/Modals/DepositModal.tsx | 2 +- .../_components/Ledger/Modals/PayoutModal.tsx | 2 +- .../Transactions/LedgerTransactionList.tsx | 6 +- src/app/_components/Stripe/StripePayment.tsx | 20 +++++- src/app/_components/Stripe/StripeProvider.tsx | 16 +++-- src/app/admin/accounts/[accountId]/page.tsx | 2 +- .../[accountId]/transactions/page.tsx | 2 +- src/prisma/schema/ledger.prisma | 14 ++-- src/prisma/schema/user.prisma | 64 +++++++++++-------- src/services/ledger/ledgerAccount/methods.ts | 1 + .../ledger/ledgerTransactions/actions.ts | 4 +- src/services/ledger/payments/config.ts | 1 + src/services/ledger/stripeCustomer/methods.ts | 31 +++++++++ 19 files changed, 132 insertions(+), 72 deletions(-) create mode 100644 src/services/ledger/payments/config.ts create mode 100644 src/services/ledger/stripeCustomer/methods.ts diff --git a/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx index 1aa1f43cb..5285bc4ab 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx @@ -9,7 +9,7 @@ type Props = { } export default async function LedgerAccountBalance({ ledgerAccountId: accountId, showFees }: Props) { - const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: accountId})) + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: accountId })) return
diff --git a/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx index 78b0bc507..b67164ebc 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx @@ -1,7 +1,7 @@ -import Card from "@/components/UI/Card"; -import DepositModal from "@/components/Ledger/Modals/DepositModal"; -import PayoutModal from "@/components/Ledger/Modals/PayoutModal"; -import LedgerAccountBalance from "./LedgerAccountBalance"; +import LedgerAccountBalance from './LedgerAccountBalance' +import Card from '@/components/UI/Card' +import DepositModal from '@/components/Ledger/Modals/DepositModal' +import PayoutModal from '@/components/Ledger/Modals/PayoutModal' type Props = { ledgerAccountId: number, @@ -10,8 +10,8 @@ type Props = { export default function LedgerAccountOverview({ ledgerAccountId }: Props) { return

Kontooversikt

- + -
-} \ No newline at end of file + +} diff --git a/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx index e7eb8fc35..2a7e096ba 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx @@ -1,6 +1,6 @@ -import Card from "@/components/UI/Card"; -import BankCardModal from "../Modals/BankCardModal"; -import Link from "next/link"; +import BankCardModal from '@/components/Ledger/Modals/BankCardModal' +import Card from '@/components/UI/Card' +import Link from 'next/link' type Props = { userId: number, diff --git a/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx index 9060b4c10..096840a5d 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx +++ b/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx @@ -1,5 +1,5 @@ -import Card from "@/components/UI/Card"; -import Link from "next/link"; +import Card from '@/components/UI/Card' +import Link from 'next/link' type Props = { transactionsHref?: string, @@ -20,4 +20,4 @@ export default function LedgerAccountTransactionSummary({ transactionsHref }: Pr { transactionsHref && Se alle transaksjoner -> } -} \ No newline at end of file +} diff --git a/src/app/_components/Ledger/Modals/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx index 6618f5cb7..8c9477af2 100644 --- a/src/app/_components/Ledger/Modals/BankCardModal.tsx +++ b/src/app/_components/Ledger/Modals/BankCardModal.tsx @@ -2,7 +2,8 @@ import PopUp from '@/app/_components/PopUp/PopUp' import Button from '@/app/_components/UI/Button' -import { CardElement } from '@stripe/react-stripe-js' +import StripePayment from '@/components/Stripe/StripePayment' +import StripeProvider from '@/components/Stripe/StripeProvider' type PropTypes = { userId: number, @@ -16,7 +17,9 @@ export default function BankCardModal({ userId }: PropTypes) { >

Legg til bankkort

TODO

- {/* */} - + + + + ) } diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx index e1cbfd266..e27e11d94 100644 --- a/src/app/_components/Ledger/Modals/CheckoutModal.tsx +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -3,12 +3,12 @@ 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' -import { createActionError } from '@/services/actionError' const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) const StripePayment = lazy(() => import('../../Stripe/StripePayment')) diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 9c9b82916..ba8d604bb 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -7,11 +7,11 @@ import NumberInput from '../../UI/NumberInput' import Button from '../../UI/Button' import { createDepositAction } from '@/services/ledger/ledgerOperations/actions' import { convertAmount, displayAmount } from '@/lib/currency/convert' +import { createActionError } from '@/services/actionError' import { lazy, useRef, useState } from 'react' import type { PaymentProvider } from '@prisma/client' import type { ExpandedPayment } from '@/services/ledger/payments/Types' import type { StripePaymentRef } from '../../Stripe/StripePayment' -import { createActionError } from '@/services/actionError' // Avoid loading the Stripe components until they are needed const StripePayment = lazy(() => import('../../Stripe/StripePayment')) diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx index fe04b6a10..e7f4f5e22 100644 --- a/src/app/_components/Ledger/Modals/PayoutModal.tsx +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -7,8 +7,8 @@ import NumberInput from '../../UI/NumberInput' import Button from '../../UI/Button' import { createPayout } from '@/services/ledger/ledgerOperations/actions' import { convertAmount } from '@/lib/currency/convert' -import { useState } from 'react' import { bindParams } from '@/services/actionBind' +import { useState } from 'react' type Props = { ledgerAccountId: number, diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx index f1cf8d12c..0d2f8257e 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -1,9 +1,9 @@ 'use client' import styles from './LedgerTransactionList.module.scss' -import EndlessScroll from "@/components/PagingWrappers/EndlessScroll" -import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from "@/contexts/paging/LedgerTranasctionPaging" -import LedgerTransactionRow from "./LedgerTransactionRow" +import LedgerTransactionRow from './LedgerTransactionRow' +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTranasctionPaging' type Props = { accountId: number, diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx index c3d9967ab..5e6626459 100644 --- a/src/app/_components/Stripe/StripePayment.tsx +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -3,11 +3,12 @@ import React, { useImperativeHandle } from 'react' export type StripePaymentRef = { submit: () => Promise; - confirm: (clientSecret: string) => Promise; + confirmPayment: (clientSecret: string) => Promise; + confirmSetup: (clientSecret: string) => Promise; } type Props = { - ref: React.Ref, + ref?: React.Ref, } export default function StripePayment({ ref }: Props) { @@ -22,7 +23,7 @@ export default function StripePayment({ ref }: Props) { if (error) return error.message || 'En feil oppsto når betalingen skulle sendes inn.' }, - confirm: async (clientSecret: string) => { + confirmPayment: async (clientSecret: string) => { if (!stripe || !elements) return 'Stripe ikke initialisert enda.' const { error } = await stripe.confirmPayment({ @@ -34,6 +35,19 @@ export default function StripePayment({ ref }: Props) { }) 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.' } })) diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx index 4b7303198..a6936c5a7 100644 --- a/src/app/_components/Stripe/StripeProvider.tsx +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -1,7 +1,8 @@ '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 { CustomerOptions, loadStripe } from '@stripe/stripe-js' import type { ReactNode } from 'react' if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { @@ -11,16 +12,19 @@ if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) type Props = { - amount: number, - children?: ReactNode + children?: ReactNode, + mode: 'payment' | 'setup', + amount?: number, + customerOptions?: CustomerOptions, } -export default function StripeProvider({ children, amount }: Props) { +export default function StripeProvider({ children, mode, amount, customerOptions }: Props) { return ( {children} diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx index 9b09e0421..ff66c9a6b 100644 --- a/src/app/admin/accounts/[accountId]/page.tsx +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -1,3 +1,3 @@ export default async function LedgerAccount() { return 'En konto' -} \ No newline at end of file +} diff --git a/src/app/admin/accounts/[accountId]/transactions/page.tsx b/src/app/admin/accounts/[accountId]/transactions/page.tsx index e690a0813..0083b05f2 100644 --- a/src/app/admin/accounts/[accountId]/transactions/page.tsx +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -1,3 +1,3 @@ export default async function LedgerAccountTransactions() { return 'Transaksjoner' -} \ No newline at end of file +} diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 6c918a249..751815aaf 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -154,13 +154,13 @@ model Payment { } 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 - payment Payment @relation(fields: [paymentId], references: [id]) - paymentId Int @unique - // Which ledger entries have used this payment // Useful in case payment goes through, // then user can be credited unused funds @@ -175,16 +175,14 @@ model StripePayment { // 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? - payment Payment @relation(fields: [paymentId], references: [id]) - paymentId Int @unique - createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } - -// TODO: Stripe customer model diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index 173871cd1..69b2f263c 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -5,41 +5,49 @@ 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? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // is also updated manually + image Image? @relation(fields: [imageId], references: [id]) + imageId Int? + studentCard String? @unique + + credentials Credentials? + feideAccount FeideAccount? + + memberships Membership[] + LockerReservation LockerReservation[] omegaQuote OmegaQuote[] - memberships Membership[] - credentials Credentials? - feideAccount FeideAccount? - ledgerAccount LedgerAccount? + + ledgerAccount LedgerAccount? 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") + admissionTrials AdmissionTrial[] @relation(name: "user") + registeredAdmissionTrial AdmissionTrial[] @relation(name: "registeredBy") + + Application Application[] + + EventRegistration EventRegistration[] + Event Event[] + + dots DotWrapper[] @relation(name: "dot_user") + dotsAccused DotWrapper[] @relation(name: "dot_accuser") + registerStudentCardQueue RegisterStudentCardQueue[] - Event Event[] cabinBooking Booking[] @relation() diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index d7e586d25..88337ca90 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -4,6 +4,7 @@ import { ServerError } from '@/services/error' import { serviceMethod } from '@/services/serviceMethod' import { z } from 'zod' import type { BalanceRecord } from './Types' +import { stripe } from '@/lib/stripe' export namespace LedgerAccountMethods { /** diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts index 574d72c77..384f61d61 100644 --- a/src/services/ledger/ledgerTransactions/actions.ts +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -1,4 +1,4 @@ -import { action } from '@/services/action'; -import { LedgerTransactionMethods } from './methods'; +import { LedgerTransactionMethods } from './methods' +import { action } from '@/services/action' export const readLedgerTransactionPageAction = action(LedgerTransactionMethods.readPage) diff --git a/src/services/ledger/payments/config.ts b/src/services/ledger/payments/config.ts new file mode 100644 index 000000000..ca8197844 --- /dev/null +++ b/src/services/ledger/payments/config.ts @@ -0,0 +1 @@ +export const MINIMUM_PAYMENT_AMOUNT = 500 // In hundredths of Kluengende Muente diff --git a/src/services/ledger/stripeCustomer/methods.ts b/src/services/ledger/stripeCustomer/methods.ts new file mode 100644 index 000000000..2499be3c2 --- /dev/null +++ b/src/services/ledger/stripeCustomer/methods.ts @@ -0,0 +1,31 @@ +import { stripe } from "@/lib/stripe"; +import { serviceMethod } from "@/services/serviceMethod"; +import { z } from "zod"; + +export namespace StripeCustomerMethods { + export const create = serviceMethod({ + paramsSchema: z.object({ + userId: z.number(), + }), + method: async ({ params: { userId }, prisma }) => { + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { firstname: true, lastname: true, email: true, emailVerified: true, } + }) + // Check if customer already exists + + stripe.customers.create({ + email, + }) + }, + }) + + export const readOrCreate = serviceMethod({ + paramsSchema: z.object({ + userId: z.number(), + }), + method: async ({ params }) => { + + } + }) +} \ No newline at end of file From 4527f2ac75a6d1e1a7d5e44f3a3755ed3eacf626 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 22 Sep 2025 23:00:24 +0200 Subject: [PATCH 37/62] feat: working transaction lists ++ --- .../Account/LedgerAccountOverviewCard.tsx | 17 ---------- .../LedgerAccountPaymentMethodsCard.tsx | 24 -------------- .../LedgerAccountBalance.module.scss | 11 +------ .../LedgerAccountBalance.tsx | 4 +-- .../Accounts/LedgerAccountList.module.scss | 5 +++ .../Ledger/Accounts/LedgerAccountList.tsx | 27 ++++++++++++++++ .../LedgerAccountOverview.module.scss | 11 +++++++ .../Accounts/LedgerAccountOverviewCard.tsx | 31 ++++++++++++++++++ .../LedgerAccountPaymentMethodsCard.tsx | 30 +++++++++++++++++ .../LedgerAccountTransactionSummaryCard.tsx} | 8 +++-- .../Ledger/Modals/DepositModal.tsx | 4 +-- .../Transactions/LedgerTransactionList.tsx | 5 +-- src/app/_components/NavBar/navDef.ts | 2 +- src/app/_components/PopUp/PopUp.module.scss | 2 ++ src/app/_components/PopUp/PopUp.tsx | 4 +-- src/app/_components/UI/BooleanIndicator.tsx | 12 +++++++ src/app/_components/UI/Button.module.scss | 8 ++--- src/app/_components/UI/Card.module.scss | 6 +++- src/app/_components/UI/Card.tsx | 8 +++-- src/app/admin/accounts/[accountId]/page.tsx | 24 ++++++++++++-- .../[accountId]/transactions/page.tsx | 19 +++++++++-- src/app/admin/accounts/page.tsx | 6 ++-- src/app/users/[username]/(user-admin)/Nav.tsx | 4 +-- .../[username]/(user-admin)/account/page.tsx | 12 +++---- src/contexts/paging/LedgerAccountPaging.tsx | 25 +++++++++++++++ ...Paging.tsx => LedgerTransactionPaging.tsx} | 5 ++- src/prisma/schema/ledger.prisma | 1 + .../seeder/src/development/seedDevGroups.ts | 7 ++-- src/services/ledger/ledgerAccount/actions.ts | 1 + src/services/ledger/ledgerAccount/methods.ts | 32 +++++++++++++++++-- .../ledger/ledgerTransactions/actions.ts | 2 ++ .../ledger/ledgerTransactions/methods.ts | 8 ++--- src/services/ledger/stripeCustomer/methods.ts | 9 ++++-- src/services/users/constants.ts | 1 + src/styles/_mixins.scss | 4 +-- 35 files changed, 280 insertions(+), 99 deletions(-) delete mode 100644 src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx delete mode 100644 src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx rename src/app/_components/Ledger/{Account => Accounts}/LedgerAccountBalance.module.scss (80%) rename src/app/_components/Ledger/{Account => Accounts}/LedgerAccountBalance.tsx (86%) create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountList.module.scss create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountList.tsx create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx rename src/app/_components/Ledger/{Account/LedgerAccountTransactionsCard.tsx => Accounts/LedgerAccountTransactionSummaryCard.tsx} (66%) create mode 100644 src/app/_components/UI/BooleanIndicator.tsx create mode 100644 src/contexts/paging/LedgerAccountPaging.tsx rename src/contexts/paging/{LedgerTranasctionPaging.tsx => LedgerTransactionPaging.tsx} (76%) diff --git a/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx deleted file mode 100644 index b67164ebc..000000000 --- a/src/app/_components/Ledger/Account/LedgerAccountOverviewCard.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import LedgerAccountBalance from './LedgerAccountBalance' -import Card from '@/components/UI/Card' -import DepositModal from '@/components/Ledger/Modals/DepositModal' -import PayoutModal from '@/components/Ledger/Modals/PayoutModal' - -type Props = { - ledgerAccountId: number, -} - -export default function LedgerAccountOverview({ ledgerAccountId }: Props) { - return -

Kontooversikt

- - - -
-} diff --git a/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx deleted file mode 100644 index 2a7e096ba..000000000 --- a/src/app/_components/Ledger/Account/LedgerAccountPaymentMethodsCard.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import BankCardModal from '@/components/Ledger/Modals/BankCardModal' -import Card from '@/components/UI/Card' -import Link from 'next/link' - -type Props = { - userId: number, -} - -export default function LedgerAccountPaymentMethods({ userId }: Props) { - return -

Betalingsalternativer

-

Bankkort

-

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

- -

NTNU-kort

-

- For å benytte Kioleskabet på Lophtet må et NTNU-kort være tilknyttet brukeren din. -

- Gå til siden for kortregistrering. -
-} diff --git a/src/app/_components/Ledger/Account/LedgerAccountBalance.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss similarity index 80% rename from src/app/_components/Ledger/Account/LedgerAccountBalance.module.scss rename to src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss index f4ee09411..f2fa6b148 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountBalance.module.scss +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.module.scss @@ -2,7 +2,7 @@ .LedgerAccountBalance { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto 1fr auto; grid-auto-flow: row; grid-auto-columns: max-content; column-gap: 2*ohma.$gap; @@ -30,12 +30,3 @@ text-align: right; // width: 100%; // ensures it stretches to the container } - -.currencySymbol { - display: none; - - - // @include ohma.screenMobile { - // display: none; - // } -} \ No newline at end of file diff --git a/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx similarity index 86% rename from src/app/_components/Ledger/Account/LedgerAccountBalance.tsx rename to src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx index 5285bc4ab..1bebcfaa2 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx @@ -15,12 +15,12 @@ export default async function LedgerAccountBalance({ ledgerAccountId: accountId,
Saldo
{displayAmount(balance.amount)}
-
Kluengende Muente
+
Muenter
{showFees &&
Avgifter
{displayAmount(balance.fees)}
-
Kluengende Muente
+
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..6f55e03da --- /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..d5f9ae230 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -0,0 +1,31 @@ +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' + +type Props = { + ledgerAccountId: number, + showFees?: boolean, + showDepositButton?: boolean, + showPayoutButton?: boolean, + showDeactivateButton?: boolean, +} + +export default function LedgerAccountOverview({ + showFees, + ledgerAccountId, + showPayoutButton, + showDepositButton, + showDeactivateButton, +}: Props) { + 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..1bb312624 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -0,0 +1,30 @@ +import BankCardModal from '@/components/Ledger/Modals/BankCardModal' +import Card from '@/components/UI/Card' +import Link from 'next/link' +import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { readUserAction } from '@/services/users/actions' +import BooleanIndicator from '@/components/UI/BooleanIndicator' + +type Props = { + userId: number, +} + +export default async function LedgerAccountPaymentMethods({ userId }: Props) { + const user = unwrapActionReturn(await readUserAction({ id: 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/Account/LedgerAccountTransactionsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx similarity index 66% rename from src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx rename to src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx index 096840a5d..36e825d0d 100644 --- a/src/app/_components/Ledger/Account/LedgerAccountTransactionsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx @@ -1,13 +1,15 @@ 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 -

Transaksjoner

+ return @@ -18,6 +20,6 @@ export default function LedgerAccountTransactionSummary({ transactionsHref }: Pr
- { transactionsHref && Se alle transaksjoner -> } + { transactionsHref && Se alle transaksjoner }
} diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index ba8d604bb..f4bfc4ce0 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -48,7 +48,7 @@ export default function DepositModal({ ledgerAccountId }: Props) { if (!current) return 'Noe gikk galt ved innhenting av Stripe.' // Call the stripe payment ref to confirm the payment - const confirmError = await current.confirm(clientSecret) + const confirmError = await current.confirmPayment(clientSecret) if (confirmError) return confirmError } @@ -107,7 +107,7 @@ export default function DepositModal({ ledgerAccountId }: Props) { {selectedProvider === 'STRIPE' && ( - + )} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx index 0d2f8257e..8edd8d46e 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -1,9 +1,9 @@ 'use client' -import styles from './LedgerTransactionList.module.scss' import LedgerTransactionRow from './LedgerTransactionRow' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTranasctionPaging' +import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTransactionPaging' +import { readLedgerAccountPageAction } from '@/services/ledger/ledgerAccount/actions' type Props = { accountId: number, @@ -18,5 +18,6 @@ export default function TransactionList({ accountId }: Props) { transaction => } /> +

Her var det tomt! Hva med å ta seg en tur innom Kiogeskapet?

} 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/PopUp/PopUp.module.scss b/src/app/_components/PopUp/PopUp.module.scss index ca3981bc8..cf28411b7 100644 --- a/src/app/_components/PopUp/PopUp.module.scss +++ b/src/app/_components/PopUp/PopUp.module.scss @@ -24,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 b58fd9bed..53db2176f 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -4,7 +4,7 @@ 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' @@ -56,7 +56,7 @@ export default function PopUp({
{ children } diff --git a/src/app/_components/UI/BooleanIndicator.tsx b/src/app/_components/UI/BooleanIndicator.tsx new file mode 100644 index 000000000..cb48fd76a --- /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 + ? + : +} \ No newline at end of file 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 index 761d0c18c..e6864d51d 100644 --- a/src/app/_components/UI/Card.module.scss +++ b/src/app/_components/UI/Card.module.scss @@ -5,8 +5,12 @@ $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); + 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 index 01a50ebf7..200d3faab 100644 --- a/src/app/_components/UI/Card.tsx +++ b/src/app/_components/UI/Card.tsx @@ -3,12 +3,16 @@ import type { ReactNode } from 'react' type PropTypes = { children?: ReactNode, + heading?: string, } -export default function Card({ children }: PropTypes) { +export default function Card({ children, heading }: PropTypes) { return (
- {children} + {heading &&

{heading}

} +
+ {children} +
) } diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx index ff66c9a6b..23ef70392 100644 --- a/src/app/admin/accounts/[accountId]/page.tsx +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -1,3 +1,23 @@ -export default async function LedgerAccount() { - return 'En konto' +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 index 0083b05f2..8bd2bdf9b 100644 --- a/src/app/admin/accounts/[accountId]/transactions/page.tsx +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -1,3 +1,18 @@ -export default async function LedgerAccountTransactions() { - return 'Transaksjoner' +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 index c1342bbca..477d31089 100644 --- a/src/app/admin/accounts/page.tsx +++ b/src/app/admin/accounts/page.tsx @@ -1,3 +1,5 @@ -export default async function LedgerAccountList() { - return 'Yo' +import LedgerAccountList from "@/components/Ledger/Accounts/LedgerAccountList"; + +export default async function LedgerAccounts() { + return } diff --git a/src/app/users/[username]/(user-admin)/Nav.tsx b/src/app/users/[username]/(user-admin)/Nav.tsx index 5875e4476..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, faMoneyBill, 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 = { @@ -36,7 +36,7 @@ export default function Nav({ username }: PropTypes) { href={`/users/${username}/account`} className={page === 'account' ? styles.selected : undefined} > - + - + - +
} diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx new file mode 100644 index 000000000..53f61351a --- /dev/null +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -0,0 +1,25 @@ +'use client' + +import { LedgerAccount } from '@prisma/client' +import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { readLedgerAccountPageAction } from '@/services/ledger/ledgerAccount/actions' +import { 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/LedgerTranasctionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx similarity index 76% rename from src/contexts/paging/LedgerTranasctionPaging.tsx rename to src/contexts/paging/LedgerTransactionPaging.tsx index 1eaed27c1..41b54a7f1 100644 --- a/src/contexts/paging/LedgerTranasctionPaging.tsx +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -17,7 +17,10 @@ export const LedgerTransactionPagingContext = generatePagingContext< const LedgerTransactionPagingProvider = generatePagingProvider({ Context: LedgerTransactionPagingContext, fetcher: (paging) => readLedgerTransactionPageAction({ paging }), - getCursorAfterFetch: data => ({ id: data[data.length - 1].id }), + 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/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 751815aaf..b9f52e090 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -20,6 +20,7 @@ model LedgerAccount { 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[] diff --git a/src/prisma/seeder/src/development/seedDevGroups.ts b/src/prisma/seeder/src/development/seedDevGroups.ts index 68887804c..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' } } } @@ -37,6 +37,7 @@ export default async function seedDevGroups(prisma: PrismaClient) { order: order.order, ledgerAccount: { create: { + name: `Kontoen til Harambes komité`, type: 'GROUP', }, }, diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts index 84a3da7c2..d6396d1ce 100644 --- a/src/services/ledger/ledgerAccount/actions.ts +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -4,3 +4,4 @@ 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/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index 88337ca90..ac7fce8d8 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -5,6 +5,9 @@ import { serviceMethod } from '@/services/serviceMethod' import { z } from 'zod' import type { BalanceRecord } from './Types' import { stripe } from '@/lib/stripe' +import { readPageInputSchemaObject } from '@/lib/paging/schema' +import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' +import { LedgerAccountType } from '@prisma/client' export namespace LedgerAccountMethods { /** @@ -80,6 +83,32 @@ export namespace LedgerAccountMethods { }, }) + 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 + return 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. * @@ -169,13 +198,12 @@ export namespace LedgerAccountMethods { id: z.number(), atTransactionId: z.number().optional(), }), - method: async ({ prisma, session, params }) => { + method: async ({ params }) => { const balances = await calculateBalances({ params: { ids: [params.id], atTransactionId: params.atTransactionId, }, - session, }) return balances[params.id] diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts index 384f61d61..b870681a4 100644 --- a/src/services/ledger/ledgerTransactions/actions.ts +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -1,3 +1,5 @@ +"use server" + import { LedgerTransactionMethods } from './methods' import { action } from '@/services/action' diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index b3a685c7e..260a23997 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -70,10 +70,10 @@ export namespace LedgerTransactionMethods { }, }, }, - orderBy: { - createdAt: 'desc', - id: 'desc', - }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc'}, + ], ...cursorPageingSelection(params.paging.page) }) }) diff --git a/src/services/ledger/stripeCustomer/methods.ts b/src/services/ledger/stripeCustomer/methods.ts index 2499be3c2..0881a7c0e 100644 --- a/src/services/ledger/stripeCustomer/methods.ts +++ b/src/services/ledger/stripeCustomer/methods.ts @@ -1,6 +1,9 @@ -import { stripe } from "@/lib/stripe"; -import { serviceMethod } from "@/services/serviceMethod"; -import { z } from "zod"; +import { stripe } from "@/lib/stripe" +import { serviceMethod } from "@/services/serviceMethod" +import { z } from "zod" + +// We have no reason to store Stripe customer ids in the database +// so this service only interfaces with the stripe API. export namespace StripeCustomerMethods { export const create = serviceMethod({ 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 { From c156d178db902f349658482285153de8afb7d472 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 22 Sep 2025 23:54:34 +0200 Subject: [PATCH 38/62] feat: ledger list ui polish --- .../Ledger/Modals/BankCardModal.module.scss | 10 +++++ .../Ledger/Modals/BankCardModal.tsx | 12 +++--- .../Ledger/Modals/DepositModal.tsx | 42 ++++++++++++------- .../_components/Ledger/Modals/PayoutModal.tsx | 1 + .../Transactions/LedgerTransactionList.tsx | 9 ++-- .../PagingWrappers/EndlessScroll.tsx | 6 ++- .../[username]/(user-admin)/account/page.tsx | 2 +- src/services/ledger/payments/config.ts | 2 +- 8 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 src/app/_components/Ledger/Modals/BankCardModal.module.scss 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 index 8c9477af2..9233945e8 100644 --- a/src/app/_components/Ledger/Modals/BankCardModal.tsx +++ b/src/app/_components/Ledger/Modals/BankCardModal.tsx @@ -1,5 +1,6 @@ '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' @@ -16,10 +17,11 @@ export default function BankCardModal({ userId }: PropTypes) { customShowButton={(open) => } >

Legg til bankkort

-

TODO

- - - - +
+ + + +
+ ) } diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index f4bfc4ce0..c26afe44f 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -1,23 +1,24 @@ 'use client' import styles from './DepositModal.module.scss' -import Form from '../../Form/Form' -import PopUp from '../../PopUp/PopUp' -import NumberInput from '../../UI/NumberInput' -import Button from '../../UI/Button' +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, displayAmount } from '@/lib/currency/convert' +import { convertAmount } from '@/lib/currency/convert' import { createActionError } from '@/services/actionError' import { lazy, useRef, useState } from 'react' import type { PaymentProvider } from '@prisma/client' import type { ExpandedPayment } from '@/services/ledger/payments/Types' -import type { StripePaymentRef } from '../../Stripe/StripePayment' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' +import Checkbox from '@/components/UI/Checkbox' +import TextInput from '@/components/UI/TextInput' // Avoid loading the Stripe components until they are needed -const StripePayment = lazy(() => import('../../Stripe/StripePayment')) -const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) - -const minFunds = 50_00 +const StripePayment = lazy(() => import('@/components/Stripe/StripePayment')) +const StripeProvider = lazy(() => import('@/components/Stripe/StripeProvider')) const defaultPaymentProvider: PaymentProvider = 'STRIPE' const paymentProviderNames: Record = { @@ -30,7 +31,8 @@ type Props = { } export default function DepositModal({ ledgerAccountId }: Props) { - const [funds, setFunds] = useState(minFunds) + const [funds, setFunds] = useState(MINIMUM_PAYMENT_AMOUNT) + const [manualFees, setManualFees] = useState(0) const [selectedProvider, setSelectedProvider] = useState(defaultPaymentProvider) const stripePaymentRef = useRef(null) @@ -52,7 +54,7 @@ export default function DepositModal({ ledgerAccountId }: Props) { if (confirmError) return confirmError } - const handleSubmit = async (data: FormData) => { + const handleSubmit = async (_: FormData) => { // If the stripe payment ref is set, validate the input if (stripePaymentRef.current) { const submitError = await stripePaymentRef.current.submit() @@ -79,14 +81,16 @@ export default function DepositModal({ ledgerAccountId }: Props) { return }>
+

Nytt innskudd

setFunds(convertAmount(e.target.value))} + required />
@@ -114,7 +118,17 @@ export default function DepositModal({ ledgerAccountId }: Props) { {selectedProvider === 'MANUAL' && (
-

Etter innsending vil du motta instruksjoner for manuell betaling.

+ Jeg bruker dette med ohmu. + setManualFees(convertAmount(e.target.value))} + required + /> +
)} diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx index e7f4f5e22..f00e32138 100644 --- a/src/app/_components/Ledger/Modals/PayoutModal.tsx +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -21,6 +21,7 @@ export default function PayoutModal({ ledgerAccountId, defaultFunds = 0, default const [fees, setFees] = useState(defaultFees) return }> +

Ny utbetaling

+ transaction => } /> -

Her var det tomt! Hva med å ta seg en tur innom Kiogeskapet?

+ {/* 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/PagingWrappers/EndlessScroll.tsx b/src/app/_components/PagingWrappers/EndlessScroll.tsx index 73dd81a3d..00fd7fcdb 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/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index c88c71623..d83e4d27f 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -18,6 +18,6 @@ export default async function Account() { return
- +
} diff --git a/src/services/ledger/payments/config.ts b/src/services/ledger/payments/config.ts index ca8197844..98685cb0c 100644 --- a/src/services/ledger/payments/config.ts +++ b/src/services/ledger/payments/config.ts @@ -1 +1 @@ -export const MINIMUM_PAYMENT_AMOUNT = 500 // In hundredths of Kluengende Muente +export const MINIMUM_PAYMENT_AMOUNT = 50_00 // In hundredths of Kluengende Muente From f832d589a816409e7825981031e1d78bdf9e5d05 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 23 Sep 2025 17:09:30 +0200 Subject: [PATCH 39/62] style: linting, linting and more linting --- .../Ledger/Accounts/LedgerAccountList.tsx | 10 +++++----- .../Ledger/Accounts/LedgerAccountOverviewCard.tsx | 6 +++--- .../Accounts/LedgerAccountPaymentMethodsCard.tsx | 8 ++++---- src/app/_components/Ledger/Modals/BankCardModal.tsx | 4 ++-- src/app/_components/Ledger/Modals/DepositModal.tsx | 6 +++--- src/app/_components/PagingWrappers/EndlessScroll.tsx | 6 +++--- src/app/_components/Stripe/StripePayment.tsx | 2 +- src/app/_components/Stripe/StripeProvider.tsx | 3 ++- src/app/_components/UI/BooleanIndicator.tsx | 6 +++--- src/app/admin/accounts/[accountId]/page.tsx | 6 +++--- .../admin/accounts/[accountId]/transactions/page.tsx | 4 ++-- src/app/admin/accounts/page.tsx | 2 +- .../users/[username]/(user-admin)/account/page.tsx | 2 +- src/contexts/paging/LedgerAccountPaging.tsx | 3 +-- src/services/ledger/ledgerAccount/methods.ts | 12 ++++++------ src/services/ledger/ledgerOperations/schemas.ts | 1 - src/services/ledger/ledgerTransactions/actions.ts | 2 +- src/services/ledger/ledgerTransactions/methods.ts | 6 ++++-- src/services/ledger/stripeCustomer/methods.ts | 10 +++++----- 19 files changed, 50 insertions(+), 49 deletions(-) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx index 6f55e03da..34850715a 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx @@ -1,12 +1,12 @@ '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'; +import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' +import LedgerAccountPagingProvider, { LedgerAccountPagingContext } from '@/contexts/paging/LedgerAccountPaging' +import Link from 'next/link' export default function LedgerAccountList() { - return + return @@ -15,7 +15,7 @@ export default function LedgerAccountList() { - + diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index d5f9ae230..9c45587ae 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -15,8 +15,8 @@ type Props = { export default function LedgerAccountOverview({ showFees, - ledgerAccountId, - showPayoutButton, + ledgerAccountId, + showPayoutButton, showDepositButton, showDeactivateButton, }: Props) { @@ -25,7 +25,7 @@ export default function LedgerAccountOverview({
{ showDepositButton && } { showPayoutButton && } - { showDeactivateButton && } + { showDeactivateButton && }
} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index 1bb312624..5f50f8541 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -1,20 +1,20 @@ import BankCardModal from '@/components/Ledger/Modals/BankCardModal' import Card from '@/components/UI/Card' -import Link from 'next/link' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { readUserAction } from '@/services/users/actions' import BooleanIndicator from '@/components/UI/BooleanIndicator' +import Link from 'next/link' type Props = { userId: number, } export default async function LedgerAccountPaymentMethods({ userId }: Props) { - const user = unwrapActionReturn(await readUserAction({ id: userId})) + const user = unwrapActionReturn(await readUserAction({ id: userId })) const hasBankCard = false // TODO: Actually check with Stripe const hasStudentCard = user.studentCard !== null - + return

Bankkort

@@ -23,7 +23,7 @@ export default async function LedgerAccountPaymentMethods({ userId }: Props) {

NTNU-kort

-

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

+

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/Modals/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx index 9233945e8..4ebb9ea46 100644 --- a/src/app/_components/Ledger/Modals/BankCardModal.tsx +++ b/src/app/_components/Ledger/Modals/BankCardModal.tsx @@ -17,8 +17,8 @@ export default function BankCardModal({ userId }: PropTypes) { customShowButton={(open) => } >

Legg til bankkort

-
- +
+
diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index c26afe44f..219f34b63 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -8,13 +8,13 @@ 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' -import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' -import Checkbox from '@/components/UI/Checkbox' -import TextInput from '@/components/UI/TextInput' // Avoid loading the Stripe components until they are needed const StripePayment = lazy(() => import('@/components/Stripe/StripePayment')) diff --git a/src/app/_components/PagingWrappers/EndlessScroll.tsx b/src/app/_components/PagingWrappers/EndlessScroll.tsx index 00fd7fcdb..426c29348 100644 --- a/src/app/_components/PagingWrappers/EndlessScroll.tsx +++ b/src/app/_components/PagingWrappers/EndlessScroll.tsx @@ -68,10 +68,10 @@ export default function EndlessScroll {renderedPageData} - { + { context.state.data.length == 0 - ? "Ingen data å laste inn" - : "Ingen flere å laste inn" + ? 'Ingen data å laste inn' + : 'Ingen flere å laste inn' } diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx index 5e6626459..bb7f6a00d 100644 --- a/src/app/_components/Stripe/StripePayment.tsx +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -46,7 +46,7 @@ export default function StripePayment({ ref }: Props) { return_url: window.location.href, }, }) - + if (error) return error.message || 'En feil oppsto ved lagring av informasjon.' } })) diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx index a6936c5a7..9c62e5b29 100644 --- a/src/app/_components/Stripe/StripeProvider.tsx +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -2,7 +2,8 @@ import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' import { Elements } from '@stripe/react-stripe-js' -import { CustomerOptions, loadStripe } from '@stripe/stripe-js' +import { loadStripe } from '@stripe/stripe-js' +import type { CustomerOptions } from '@stripe/stripe-js' import type { ReactNode } from 'react' if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { diff --git a/src/app/_components/UI/BooleanIndicator.tsx b/src/app/_components/UI/BooleanIndicator.tsx index cb48fd76a..94bc1d18b 100644 --- a/src/app/_components/UI/BooleanIndicator.tsx +++ b/src/app/_components/UI/BooleanIndicator.tsx @@ -1,5 +1,5 @@ -import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons" -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' type Props = { value: boolean, @@ -9,4 +9,4 @@ export default function BooleanIndicator({ value }: Props) { return value ? : -} \ No newline at end of file +} diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx index 23ef70392..844621de6 100644 --- a/src/app/admin/accounts/[accountId]/page.tsx +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -1,6 +1,6 @@ -import LedgerAccountOverview from "@/components/Ledger/Accounts/LedgerAccountOverviewCard" -import LedgerAccountTransactionSummary from "@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard" -import { notFound } from "next/navigation" +import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' +import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' +import { notFound } from 'next/navigation' type Props = { params: Promise<{ diff --git a/src/app/admin/accounts/[accountId]/transactions/page.tsx b/src/app/admin/accounts/[accountId]/transactions/page.tsx index 8bd2bdf9b..baba5a3b6 100644 --- a/src/app/admin/accounts/[accountId]/transactions/page.tsx +++ b/src/app/admin/accounts/[accountId]/transactions/page.tsx @@ -1,5 +1,5 @@ -import TransactionList from "@/components/Ledger/Transactions/LedgerTransactionList"; -import { notFound } from "next/navigation"; +import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' +import { notFound } from 'next/navigation' type Props = { params: Promise<{ diff --git a/src/app/admin/accounts/page.tsx b/src/app/admin/accounts/page.tsx index 477d31089..89303f4a7 100644 --- a/src/app/admin/accounts/page.tsx +++ b/src/app/admin/accounts/page.tsx @@ -1,4 +1,4 @@ -import LedgerAccountList from "@/components/Ledger/Accounts/LedgerAccountList"; +import LedgerAccountList from '@/components/Ledger/Accounts/LedgerAccountList' export default async function LedgerAccounts() { return diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index d83e4d27f..58853febf 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -10,7 +10,7 @@ export default async function Account() { userRequired: true, shouldRedirect: true, }) // TODO: Replace with whatever we agree should be the standard for getting user - + const account = { id: 1 } //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: account.id })) diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx index 53f61351a..ee86d6c00 100644 --- a/src/contexts/paging/LedgerAccountPaging.tsx +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -1,9 +1,8 @@ 'use client' -import { LedgerAccount } from '@prisma/client' import generatePagingProvider, { generatePagingContext } from './PagingGenerator' import { readLedgerAccountPageAction } from '@/services/ledger/ledgerAccount/actions' -import { LedgerAccountType } from '@prisma/client' +import type { LedgerAccount, LedgerAccountType } from '@prisma/client' // TODO: These paging functions always come in pairs, can we gave one function which generates both? diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/methods.ts index ac7fce8d8..8077461af 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/methods.ts @@ -2,12 +2,12 @@ import { LedgerAccountSchemas } from './schemas' import { RequireNothing } from '@/auth/auther/RequireNothing' import { ServerError } from '@/services/error' import { serviceMethod } from '@/services/serviceMethod' -import { z } from 'zod' -import type { BalanceRecord } from './Types' 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 { /** @@ -94,19 +94,19 @@ export namespace LedgerAccountMethods { accountType: z.nativeEnum(LedgerAccountType).optional(), }), ), - method: async ({ params: { paging }, prisma }) => { + method: async ({ params: { paging }, prisma }) => // TODO: Add balance to each account - return await prisma.ledgerAccount.findMany({ + await prisma.ledgerAccount.findMany({ where: { type: paging.details.accountType, }, orderBy: [ { createdAt: 'desc' }, - { id: 'desc' }, + { id: 'desc' }, ], ...cursorPageingSelection(paging.page), }) - } + }) /** diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/ledgerOperations/schemas.ts index de5115064..5f7cfc90b 100644 --- a/src/services/ledger/ledgerOperations/schemas.ts +++ b/src/services/ledger/ledgerOperations/schemas.ts @@ -1,4 +1,3 @@ -import { PaymentProvider } from '@prisma/client' import { z } from 'zod' export namespace LedgerOperationSchemas { diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts index b870681a4..4a769479f 100644 --- a/src/services/ledger/ledgerTransactions/actions.ts +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -1,4 +1,4 @@ -"use server" +'use server' import { LedgerTransactionMethods } from './methods' import { action } from '@/services/action' diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/methods.ts index 260a23997..063c4f794 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/methods.ts @@ -72,7 +72,7 @@ export namespace LedgerTransactionMethods { }, orderBy: [ { createdAt: 'desc' }, - { id: 'desc'}, + { id: 'desc' }, ], ...cursorPageingSelection(params.paging.page) }) @@ -177,7 +177,9 @@ export namespace LedgerTransactionMethods { // 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) + 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.') } diff --git a/src/services/ledger/stripeCustomer/methods.ts b/src/services/ledger/stripeCustomer/methods.ts index 0881a7c0e..722aaf5bd 100644 --- a/src/services/ledger/stripeCustomer/methods.ts +++ b/src/services/ledger/stripeCustomer/methods.ts @@ -1,6 +1,6 @@ -import { stripe } from "@/lib/stripe" -import { serviceMethod } from "@/services/serviceMethod" -import { z } from "zod" +import { stripe } from '@/lib/stripe' +import { serviceMethod } from '@/services/serviceMethod' +import { z } from 'zod' // We have no reason to store Stripe customer ids in the database // so this service only interfaces with the stripe API. @@ -28,7 +28,7 @@ export namespace StripeCustomerMethods { userId: z.number(), }), method: async ({ params }) => { - + } }) -} \ No newline at end of file +} From 6e280b9202bc6ecc6e3a2e494f5242cdfafac8af Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 23 Sep 2025 20:27:46 +0200 Subject: [PATCH 40/62] feat: save payment method --- .../Accounts/LedgerAccountOverviewCard.tsx | 24 ++- .../LedgerAccountPaymentMethodsCard.tsx | 12 +- .../Ledger/Modals/BankCardModal.tsx | 6 +- .../Ledger/Modals/DepositModal.tsx | 5 +- src/app/_components/Stripe/StripeProvider.tsx | 7 +- src/prisma/schema/ledger.prisma | 18 +++ src/prisma/schema/user.prisma | 43 +++++- src/services/ledger/stripeCustomer/methods.ts | 34 ---- src/services/stripeCustomers/actions.ts | 6 + src/services/stripeCustomers/methods.ts | 146 ++++++++++++++++++ 10 files changed, 251 insertions(+), 50 deletions(-) delete mode 100644 src/services/ledger/stripeCustomer/methods.ts create mode 100644 src/services/stripeCustomers/actions.ts create mode 100644 src/services/stripeCustomers/methods.ts diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 9c45587ae..068f548cb 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -4,6 +4,8 @@ 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, @@ -13,17 +15,35 @@ type Props = { showDeactivateButton?: boolean, } -export default function LedgerAccountOverview({ +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 && } + { showDepositButton && } { showPayoutButton && } { showDeactivateButton && }
diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index 5f50f8541..9d06ceb6d 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -4,13 +4,23 @@ 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 @@ -21,7 +31,7 @@ export default async function LedgerAccountPaymentMethods({ userId }: Props) { 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.

diff --git a/src/app/_components/Ledger/Modals/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx index 4ebb9ea46..4a711ae08 100644 --- a/src/app/_components/Ledger/Modals/BankCardModal.tsx +++ b/src/app/_components/Ledger/Modals/BankCardModal.tsx @@ -7,10 +7,10 @@ import StripePayment from '@/components/Stripe/StripePayment' import StripeProvider from '@/components/Stripe/StripeProvider' type PropTypes = { - userId: number, + customerSessionClientSecret?: string, } -export default function BankCardModal({ userId }: PropTypes) { +export default function BankCardModal({ customerSessionClientSecret }: PropTypes) { return (

Legg til bankkort

- +
diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 219f34b63..4c7281f7e 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -28,9 +28,10 @@ const paymentProviderNames: Record = { type Props = { ledgerAccountId: number, + customerSessionClientSecret?: string, } -export default function DepositModal({ ledgerAccountId }: Props) { +export default function DepositModal({ ledgerAccountId, customerSessionClientSecret }: Props) { const [funds, setFunds] = useState(MINIMUM_PAYMENT_AMOUNT) const [manualFees, setManualFees] = useState(0) const [selectedProvider, setSelectedProvider] = useState(defaultPaymentProvider) @@ -111,7 +112,7 @@ export default function DepositModal({ ledgerAccountId }: Props) { {selectedProvider === 'STRIPE' && ( - + )} diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx index 9c62e5b29..1c313a8a6 100644 --- a/src/app/_components/Stripe/StripeProvider.tsx +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -3,7 +3,6 @@ import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' import { Elements } from '@stripe/react-stripe-js' import { loadStripe } from '@stripe/stripe-js' -import type { CustomerOptions } from '@stripe/stripe-js' import type { ReactNode } from 'react' if (!process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { @@ -16,16 +15,16 @@ type Props = { children?: ReactNode, mode: 'payment' | 'setup', amount?: number, - customerOptions?: CustomerOptions, + customerSessionClientSecret?: string, } -export default function StripeProvider({ children, mode, amount, customerOptions }: Props) { +export default function StripeProvider({ children, mode, amount, customerSessionClientSecret }: Props) { return ( {children} diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index b9f52e090..b51666022 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -1,3 +1,21 @@ +// 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. diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index 69b2f263c..65e5a5ccb 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -17,38 +17,57 @@ model User { allergies String? mobile String? emailVerified DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt // is also updated manually - image Image? @relation(fields: [imageId], references: [id]) + 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[] + // 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[] + // 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[] + // Which cabin bookings the user has made. cabinBooking Booking[] @relation() // We need to explicitly mark the combination of 'id', 'username' and 'email' as @@ -56,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 @@ -69,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/services/ledger/stripeCustomer/methods.ts b/src/services/ledger/stripeCustomer/methods.ts deleted file mode 100644 index 722aaf5bd..000000000 --- a/src/services/ledger/stripeCustomer/methods.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { stripe } from '@/lib/stripe' -import { serviceMethod } from '@/services/serviceMethod' -import { z } from 'zod' - -// We have no reason to store Stripe customer ids in the database -// so this service only interfaces with the stripe API. - -export namespace StripeCustomerMethods { - export const create = serviceMethod({ - paramsSchema: z.object({ - userId: z.number(), - }), - method: async ({ params: { userId }, prisma }) => { - const user = await prisma.user.findUniqueOrThrow({ - where: { id: userId }, - select: { firstname: true, lastname: true, email: true, emailVerified: true, } - }) - // Check if customer already exists - - stripe.customers.create({ - email, - }) - }, - }) - - export const readOrCreate = serviceMethod({ - paramsSchema: z.object({ - userId: z.number(), - }), - method: async ({ params }) => { - - } - }) -} 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, + } + } + }) +} From da9c00503932ba2eb55a01e4793962786acd49c1 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 6 Jan 2026 20:10:50 +0100 Subject: [PATCH 41/62] chore: update services naming convention --- package-lock.json | 48 +++++++++++++++++-- .../Ledger/Modals/CheckoutModal.tsx | 2 +- .../Ledger/Modals/DepositModal.tsx | 4 +- .../_components/Ledger/Modals/PayoutModal.tsx | 6 +-- .../Transactions/LedgerTransactionRow.tsx | 2 +- src/app/_components/Stripe/StripeProvider.tsx | 2 +- .../paging/LedgerTransactionPaging.tsx | 2 +- src/services/ledger/ledgerAccount/actions.ts | 2 +- .../ledgerAccount/{authers.ts => auth.ts} | 0 .../{methods.ts => operations.ts} | 2 +- .../ledgerAccount/{Types.ts => types.ts} | 0 .../ledger/ledgerOperations/actions.ts | 8 ++-- .../{methods.ts => operations.ts} | 4 +- .../ledger/ledgerTransactions/actions.ts | 2 +- .../ledgerTransactions/calculateFees.ts | 2 +- .../determineTransactionState.ts | 4 +- .../{methods.ts => operations.ts} | 2 +- .../ledgerTransactions/{Type.ts => types.ts} | 0 .../payments/{config.ts => constants.ts} | 0 .../payments/{methods.ts => operations.ts} | 0 .../ledger/payments/{Types.ts => types.ts} | 0 src/services/stripeCustomers/actions.ts | 6 +-- .../{methods.ts => operations.ts} | 0 .../ledger/ledgerTransactions.test.ts | 6 +-- tests/services/ledger/payments.test.ts | 2 +- 25 files changed, 74 insertions(+), 32 deletions(-) rename src/services/ledger/ledgerAccount/{authers.ts => auth.ts} (100%) rename src/services/ledger/ledgerAccount/{methods.ts => operations.ts} (99%) rename src/services/ledger/ledgerAccount/{Types.ts => types.ts} (100%) rename src/services/ledger/ledgerOperations/{methods.ts => operations.ts} (98%) rename src/services/ledger/ledgerTransactions/{methods.ts => operations.ts} (99%) rename src/services/ledger/ledgerTransactions/{Type.ts => types.ts} (100%) rename src/services/ledger/payments/{config.ts => constants.ts} (100%) rename src/services/ledger/payments/{methods.ts => operations.ts} (100%) rename src/services/ledger/payments/{Types.ts => types.ts} (100%) rename src/services/stripeCustomers/{methods.ts => operations.ts} (100%) diff --git a/package-lock.json b/package-lock.json index 3e850c342..966f843fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,11 @@ "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", - "@stripe/react-stripe-js": "^3.3.0", - "@stripe/stripe-js": "^5.9.2", "@prisma/client": "^6.17.1", "@react-email/components": "^0.5.7", "@react-email/render": "^1.4.0", + "@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", @@ -38,8 +38,8 @@ "remark-rehype": "^11.1.2", "sass": "^1.93.2", "server-only": "^0.0.1", - "stripe": "^17.7.0", "sharp": "^0.34.4", + "stripe": "^17.7.0", "unified": "^11.0.5", "uuid": "^10.0.0", "winston": "^3.18.3", @@ -3224,6 +3224,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz", + "integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==", + "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==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5248,6 +5269,19 @@ "url": "https://dotenvx.com" } }, + "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==", + "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", @@ -9050,6 +9084,14 @@ "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==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx index e27e11d94..edfbafdc2 100644 --- a/src/app/_components/Ledger/Modals/CheckoutModal.tsx +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -5,7 +5,7 @@ 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 { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' import type { PaymentProvider } from '@prisma/client' import type { StripePaymentRef } from '../../Stripe/StripePayment' import type { ActionReturn } from '@/services/actionTypes' diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 4c7281f7e..79a2d9f98 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -8,12 +8,12 @@ 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 { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/constants' 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 { ExpandedPayment } from '@/services/ledger/payments/types' import type { StripePaymentRef } from '@/components/Stripe/StripePayment' // Avoid loading the Stripe components until they are needed diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx index f00e32138..0c80f7edf 100644 --- a/src/app/_components/Ledger/Modals/PayoutModal.tsx +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -7,8 +7,8 @@ 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' +import { configureAction } from '@/services/configureAction' type Props = { ledgerAccountId: number, @@ -19,12 +19,12 @@ type Props = { export default function PayoutModal({ ledgerAccountId, defaultFunds = 0, defaultFees = 0 }: Props) { const [funds, setFunds] = useState(defaultFunds) const [fees, setFees] = useState(defaultFees) - + createPayout() return }>

Ny utbetaling

diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx index 7b10a0170..e77f87dec 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -1,6 +1,6 @@ import styles from './LedgerTransactionRow.module.scss' import { displayAmount } from '@/lib/currency/convert' -import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/Type' +import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' type Props = { transaction: ExpandedLedgerTransaction, diff --git a/src/app/_components/Stripe/StripeProvider.tsx b/src/app/_components/Stripe/StripeProvider.tsx index 1c313a8a6..c643a021c 100644 --- a/src/app/_components/Stripe/StripeProvider.tsx +++ b/src/app/_components/Stripe/StripeProvider.tsx @@ -1,6 +1,6 @@ 'use client' -import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/config' +import { MINIMUM_PAYMENT_AMOUNT } from '@/services/ledger/payments/constants' import { Elements } from '@stripe/react-stripe-js' import { loadStripe } from '@stripe/stripe-js' import type { ReactNode } from 'react' diff --git a/src/contexts/paging/LedgerTransactionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx index 41b54a7f1..56e6ae3a7 100644 --- a/src/contexts/paging/LedgerTransactionPaging.tsx +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -2,7 +2,7 @@ import generatePagingProvider, { generatePagingContext } from './PagingGenerator' import { readLedgerTransactionPageAction } from '@/services/ledger/ledgerTransactions/actions' -import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/Type' +import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' // TODO: Might be possible to cleanup? Why is size a type??? diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts index d6396d1ce..70f449fd4 100644 --- a/src/services/ledger/ledgerAccount/actions.ts +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { LedgerAccountMethods } from './methods' +import { LedgerAccountMethods } from './operations' import { action } from '@/services/action' export const calculateLedgerAccountBalanceAction = action(LedgerAccountMethods.calculateBalance) diff --git a/src/services/ledger/ledgerAccount/authers.ts b/src/services/ledger/ledgerAccount/auth.ts similarity index 100% rename from src/services/ledger/ledgerAccount/authers.ts rename to src/services/ledger/ledgerAccount/auth.ts diff --git a/src/services/ledger/ledgerAccount/methods.ts b/src/services/ledger/ledgerAccount/operations.ts similarity index 99% rename from src/services/ledger/ledgerAccount/methods.ts rename to src/services/ledger/ledgerAccount/operations.ts index 8077461af..f16909d62 100644 --- a/src/services/ledger/ledgerAccount/methods.ts +++ b/src/services/ledger/ledgerAccount/operations.ts @@ -7,7 +7,7 @@ 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' +import type { BalanceRecord } from './types' export namespace LedgerAccountMethods { /** diff --git a/src/services/ledger/ledgerAccount/Types.ts b/src/services/ledger/ledgerAccount/types.ts similarity index 100% rename from src/services/ledger/ledgerAccount/Types.ts rename to src/services/ledger/ledgerAccount/types.ts diff --git a/src/services/ledger/ledgerOperations/actions.ts b/src/services/ledger/ledgerOperations/actions.ts index a0ff5221a..f09bff60e 100644 --- a/src/services/ledger/ledgerOperations/actions.ts +++ b/src/services/ledger/ledgerOperations/actions.ts @@ -1,7 +1,7 @@ 'use server' -import { LedgerOperationMethods } from './methods' -import { action } from '@/services/action' +import { makeAction } from '@/services/serverAction' +import { LedgerOperationMethods } from './operations' -export const createDepositAction = action(LedgerOperationMethods.createDeposit) -export const createPayout = action(LedgerOperationMethods.createPayout) +export const createDepositAction = makeAction(LedgerOperationMethods.createDeposit) +export const createPayout = makeAction(LedgerOperationMethods.createPayout) diff --git a/src/services/ledger/ledgerOperations/methods.ts b/src/services/ledger/ledgerOperations/operations.ts similarity index 98% rename from src/services/ledger/ledgerOperations/methods.ts rename to src/services/ledger/ledgerOperations/operations.ts index 03f1b74c9..3ec3145b1 100644 --- a/src/services/ledger/ledgerOperations/methods.ts +++ b/src/services/ledger/ledgerOperations/operations.ts @@ -1,5 +1,5 @@ -import { LedgerTransactionMethods } from '../ledgerTransactions/methods' -import { PaymentMethods } from '../payments/methods' +import { LedgerTransactionMethods } from '../ledgerTransactions/operations' +import { PaymentMethods } from '../payments/operations' import { RequireNothing } from '@/auth/auther/RequireNothing' import { serviceMethod } from '@/services/serviceMethod' import { z } from 'zod' diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts index 4a769479f..b40e39068 100644 --- a/src/services/ledger/ledgerTransactions/actions.ts +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { LedgerTransactionMethods } from './methods' +import { LedgerTransactionMethods } from './operations' 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 index 6b95098fb..a230b7330 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/ledgerTransactions/calculateFees.ts @@ -1,4 +1,4 @@ -import type { BalanceRecord } from '@/services/ledger/ledgerAccount/Types' +import type { BalanceRecord } from '@/services/ledger/ledgerAccount/types' /** * Calculates fees proportional to the ratio between `entryAmount` and `totalAmount`. diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts index 530df6581..62a938135 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -1,5 +1,5 @@ -import type { ExpandedLedgerTransaction } from './Type' -import type { BalanceRecord } from '@/services/ledger/ledgerAccount/Types' +import type { ExpandedLedgerTransaction } from './types' +import type { BalanceRecord } from '@/services/ledger/ledgerAccount/types' import type { LedgerTransactionState, PaymentState } from '@prisma/client' type LedgerTransactionTransition = { diff --git a/src/services/ledger/ledgerTransactions/methods.ts b/src/services/ledger/ledgerTransactions/operations.ts similarity index 99% rename from src/services/ledger/ledgerTransactions/methods.ts rename to src/services/ledger/ledgerTransactions/operations.ts index 063c4f794..267a40fba 100644 --- a/src/services/ledger/ledgerTransactions/methods.ts +++ b/src/services/ledger/ledgerTransactions/operations.ts @@ -1,6 +1,6 @@ import { calculateCreditFees, calculateDebitFees } from './calculateFees' import { determineTransactionState } from './determineTransactionState' -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/operations' import { RequireNothing } from '@/auth/auther/RequireNothing' import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' import { readPageInputSchemaObject } from '@/lib/paging/schema' diff --git a/src/services/ledger/ledgerTransactions/Type.ts b/src/services/ledger/ledgerTransactions/types.ts similarity index 100% rename from src/services/ledger/ledgerTransactions/Type.ts rename to src/services/ledger/ledgerTransactions/types.ts diff --git a/src/services/ledger/payments/config.ts b/src/services/ledger/payments/constants.ts similarity index 100% rename from src/services/ledger/payments/config.ts rename to src/services/ledger/payments/constants.ts diff --git a/src/services/ledger/payments/methods.ts b/src/services/ledger/payments/operations.ts similarity index 100% rename from src/services/ledger/payments/methods.ts rename to src/services/ledger/payments/operations.ts diff --git a/src/services/ledger/payments/Types.ts b/src/services/ledger/payments/types.ts similarity index 100% rename from src/services/ledger/payments/Types.ts rename to src/services/ledger/payments/types.ts diff --git a/src/services/stripeCustomers/actions.ts b/src/services/stripeCustomers/actions.ts index f6e7cb809..d1f35680e 100644 --- a/src/services/stripeCustomers/actions.ts +++ b/src/services/stripeCustomers/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { action } from "../action" -import { StripeCustomerMethods } from "./methods" +import { makeAction } from "@/services/serverAction" +import { StripeCustomerMethods } from "./operations" -export const createStripeCustomerSessionAction = action(StripeCustomerMethods.createSession) +export const createStripeCustomerSessionAction = makeAction(StripeCustomerMethods.createSession) diff --git a/src/services/stripeCustomers/methods.ts b/src/services/stripeCustomers/operations.ts similarity index 100% rename from src/services/stripeCustomers/methods.ts rename to src/services/stripeCustomers/operations.ts diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index 4f0ec4c0f..8fd9847b9 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -1,6 +1,6 @@ -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/methods' -import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/methods' -import { PaymentMethods } from '@/services/ledger/payments/methods' +import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/operations' +import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/operations' +import { PaymentMethods } from '@/services/ledger/payments/operations' import { UserMethods } from '@/services/users/methods' import { allSettledOrThrow } from 'tests/utils' import { prisma } from '@/prisma/client' diff --git a/tests/services/ledger/payments.test.ts b/tests/services/ledger/payments.test.ts index e4a69394b..1be46bd17 100644 --- a/tests/services/ledger/payments.test.ts +++ b/tests/services/ledger/payments.test.ts @@ -10,7 +10,7 @@ // })) import { Smorekopp } from '@/services/error' -import { PaymentMethods } from '@/services/ledger/payments/methods' +import { PaymentMethods } from '@/services/ledger/payments/operations' import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' import { prisma } from '@/prisma/client' import { PaymentProvider } from '@prisma/client' From a792bb6ac67262bfcdfa5e6dd2f7b8ceb47ec17a Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 6 Jan 2026 21:17:58 +0100 Subject: [PATCH 42/62] fix: eslint errors --- .eslintrc.json | 2 +- .../Ledger/Accounts/LedgerAccountList.tsx | 8 +- .../Accounts/LedgerAccountOverviewCard.tsx | 9 +- .../LedgerAccountPaymentMethodsCard.tsx | 6 +- .../LedgerAccountTransactionSummaryCard.tsx | 7 +- .../Ledger/Modals/CheckoutModal.tsx | 54 +++++---- .../Ledger/Modals/DepositModal.tsx | 17 ++- .../_components/Ledger/Modals/PayoutModal.tsx | 19 ++-- .../Transactions/LedgerTransactionList.tsx | 7 +- .../PagingWrappers/EndlessScroll.tsx | 2 +- src/app/_components/Stripe/StripePayment.tsx | 12 +- src/app/api/stripe-event/route.ts | 9 +- .../[username]/(user-admin)/account/page.tsx | 6 +- .../account/transactions/page.tsx | 10 +- src/contexts/paging/LedgerAccountPaging.tsx | 17 +-- .../paging/LedgerTransactionPaging.tsx | 18 +-- .../seeder/src/development/seedDevGroups.ts | 2 +- src/services/ledger/ledgerAccount/actions.ts | 8 +- .../ledger/ledgerAccount/operations.ts | 49 +++++---- src/services/ledger/ledgerAccount/schemas.ts | 18 +-- .../ledger/ledgerOperations/actions.ts | 6 +- .../ledger/ledgerOperations/operations.ts | 36 +++--- .../ledger/ledgerOperations/schemas.ts | 6 +- .../ledger/ledgerTransactions/actions.ts | 6 +- .../ledger/ledgerTransactions/operations.ts | 41 +++---- src/services/ledger/payments/operations.ts | 104 +++++++++--------- src/services/stripeCustomers/actions.ts | 6 +- src/services/stripeCustomers/operations.ts | 31 +++--- tests/services/context.test.ts | 16 +-- tests/services/ledger/ledgerAccounts.test.ts | 6 +- .../ledger/ledgerTransactions.test.ts | 24 ++-- 31 files changed, 292 insertions(+), 270 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index cf33b0085..ffdd01aae 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -165,7 +165,7 @@ // specify the maximum depth callbacks can be nested "max-nested-callbacks": [ "error", - 3 + 4 ], // disallow the omission of parentheses when invoking a constructor with no arguments "new-parens": "error", diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx index 34850715a..8ddebb3ca 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountList.tsx @@ -2,11 +2,15 @@ import styles from './LedgerAccountList.module.scss' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import LedgerAccountPagingProvider, { LedgerAccountPagingContext } from '@/contexts/paging/LedgerAccountPaging' +import { LedgerAccountPagingProvider, LedgerAccountPagingContext } from '@/contexts/paging/LedgerAccountPaging' import Link from 'next/link' export default function LedgerAccountList() { - return + return
{account.name} 19.19 Klinguende Muente
diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 068f548cb..2d8fad4eb 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -4,7 +4,7 @@ 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 { getUser } from '@/auth/session/getUser' import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' type Props = { @@ -21,7 +21,7 @@ const getCustomerSessionClientSecret = async () => { return undefined } - const customerSessionResult = await createStripeCustomerSessionAction({ userId: user.id }) + const customerSessionResult = await createStripeCustomerSessionAction({ params: { userId: user.id } }) if (!customerSessionResult.success) { return undefined } @@ -43,7 +43,10 @@ export default async function LedgerAccountOverview({ return
- { showDepositButton && } + { + showDepositButton && + + } { showPayoutButton && } { showDeactivateButton && }
diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index 9d06ceb6d..2713a64b4 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -3,15 +3,15 @@ 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' +import Link from 'next/link' type Props = { userId: number, } const getCustomerSessionClientSecret = async (userId: number) => { - const customerSessionResult = await createStripeCustomerSessionAction({ userId}) + const customerSessionResult = await createStripeCustomerSessionAction({ params: { userId } }) if (customerSessionResult.success) { return customerSessionResult.data.customerSessionClientSecret } @@ -19,7 +19,7 @@ const getCustomerSessionClientSecret = async (userId: number) => { } export default async function LedgerAccountPaymentMethods({ userId }: Props) { - const user = unwrapActionReturn(await readUserAction({ id: userId })) + const user = unwrapActionReturn(await readUserAction({ params: { id: userId } })) const customerSessionClientSecret = await getCustomerSessionClientSecret(userId) const hasBankCard = false // TODO: Actually check with Stripe diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx index 36e825d0d..c0e0dc321 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx @@ -20,6 +20,11 @@ export default function LedgerAccountTransactionSummary({ transactionsHref }: Pr
- { transactionsHref && Se alle transaksjoner } + { + transactionsHref && + + Se alle transaksjoner + + } } diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx index edfbafdc2..7dbb5ab34 100644 --- a/src/app/_components/Ledger/Modals/CheckoutModal.tsx +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -4,14 +4,14 @@ 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 React, { useState, lazy, useRef } from 'react' import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' import type { PaymentProvider } from '@prisma/client' -import type { StripePaymentRef } from '../../Stripe/StripePayment' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' import type { ActionReturn } from '@/services/actionTypes' -const StripeProvider = lazy(() => import('../../Stripe/StripeProvider')) -const StripePayment = lazy(() => import('../../Stripe/StripePayment')) +const StripeProvider = lazy(() => import('@/components/Stripe/StripeProvider')) +const StripePayment = lazy(() => import('@/components/Stripe/StripePayment')) const defaultPaymentProvider: PaymentProvider = 'STRIPE' @@ -38,7 +38,7 @@ export default function CheckoutModal({ showSummary = true, totalFunds = 100, availableFunds = 50, - manualFees = 0, + // manualFees = 0, sourceLedgerAccountId, targetLedgerAccountId, }: Props) { @@ -52,19 +52,21 @@ export default function CheckoutModal({ const fundsToTransfer = useFunds ? Math.min(totalFunds, availableFunds) : 0 const fundsToPay = Math.max(0, totalFunds - fundsToTransfer) - const handleSubmit = async (): Promise> => { + 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 (!stripePaymentRef?.current) { + return createActionError('BAD DATA', 'Stripe er ikke initialisert enda.') } - if (!result.success) { - return result + const error = await stripePaymentRef.current.submit() + + if (error) { + return createActionError('BAD DATA', error) } } + // TODO: Figure out why this is a linter error + // eslint-disable-next-line const result = await callback({ ledgerEntries: [ ...(fundsToTransfer > 0 ? [{ @@ -87,15 +89,14 @@ export default function CheckoutModal({ const transaction = result.data if (transaction.payment?.state === 'PENDING') { - if (paymentProvider !== 'STRIPE' || !transaction.payment?.stripePayment) { + if (paymentProvider !== 'STRIPE' || !transaction.payment?.stripePayment?.clientSecret) { return createActionError('BAD DATA', 'Ugyldig betalingsdata fra server.') } - stripePaymentRef.current?.confirm(transaction.payment.stripePayment.clientSecret) + stripePaymentRef.current?.confirmPayment(transaction.payment.stripePayment.clientSecret) } - const { payment } = result.data - return { success: true } + return { success: true, data: undefined } } return ( @@ -137,7 +138,7 @@ export default function CheckoutModal({
{fundsToPay > 0 && ( paymentProvider === 'STRIPE' && ( - + ) || @@ -149,12 +150,17 @@ export default function CheckoutModal({ ) )}
+ {/* {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.

- // )} */} + 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 && @@ -176,7 +182,7 @@ export default function CheckoutModal({ ) } -{/*
+/*
@@ -191,7 +197,7 @@ export default function CheckoutModal({ -
Tilgjengelig Saldo{displayAmount(amountToPay)} Kluengende Muente
*/} + */ // type PropType = { diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 79a2d9f98..7cc2871d9 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -33,6 +33,8 @@ type Props = { export default function DepositModal({ ledgerAccountId, customerSessionClientSecret }: Props) { const [funds, setFunds] = useState(MINIMUM_PAYMENT_AMOUNT) + // TODO: Actually use manual fees + // eslint-disable-next-line const [manualFees, setManualFees] = useState(0) const [selectedProvider, setSelectedProvider] = useState(defaultPaymentProvider) @@ -53,9 +55,11 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec // Call the stripe payment ref to confirm the payment const confirmError = await current.confirmPayment(clientSecret) if (confirmError) return confirmError + + return null } - const handleSubmit = async (_: FormData) => { + const handleSubmit = async () => { // If the stripe payment ref is set, validate the input if (stripePaymentRef.current) { const submitError = await stripePaymentRef.current.submit() @@ -63,7 +67,7 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec } // Call the server action to create the deposit - const createResult = await createDepositAction({ ledgerAccountId, funds, provider: selectedProvider }) + const createResult = await createDepositAction({ params: { ledgerAccountId, funds, provider: selectedProvider } }) if (!createResult.success) return createResult // The returned transaction should have a payment @@ -77,10 +81,13 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec if (confirmError) return createActionError('UNKNOWN ERROR', confirmError) } - return { success: true } as const + return { success: true, data: undefined } as const } - return }> + return } + >

Nytt innskudd

@@ -119,7 +126,7 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec {selectedProvider === 'MANUAL' && (
- Jeg bruker dette med ohmu. + Jeg bruker dette med omhu. }> + + return } + >

Ny utbetaling

diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx index 6c458e7de..e6a726f77 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -2,7 +2,7 @@ import LedgerTransactionRow from './LedgerTransactionRow' import EndlessScroll from '@/components/PagingWrappers/EndlessScroll' -import LedgerTransactionPagingProvider, { LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTransactionPaging' +import { LedgerTransactionPagingProvider, LedgerTransactionPagingContext } from '@/contexts/paging/LedgerTransactionPaging' type Props = { accountId: number, @@ -10,7 +10,10 @@ type Props = { } export default function TransactionList({ accountId, showFees }: Props) { - return + return { - context.state.data.length == 0 + context.state.data.length === 0 ? 'Ingen data å laste inn' : 'Ingen flere å laste inn' } diff --git a/src/app/_components/Stripe/StripePayment.tsx b/src/app/_components/Stripe/StripePayment.tsx index bb7f6a00d..452d28661 100644 --- a/src/app/_components/Stripe/StripePayment.tsx +++ b/src/app/_components/Stripe/StripePayment.tsx @@ -2,9 +2,9 @@ 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; + submit: () => Promise; + confirmPayment: (clientSecret: string) => Promise; + confirmSetup: (clientSecret: string) => Promise; } type Props = { @@ -22,6 +22,8 @@ export default function StripePayment({ ref }: Props) { const { error } = await elements.submit() if (error) return error.message || 'En feil oppsto når betalingen skulle sendes inn.' + + return null }, confirmPayment: async (clientSecret: string) => { if (!stripe || !elements) return 'Stripe ikke initialisert enda.' @@ -35,6 +37,8 @@ export default function StripePayment({ ref }: Props) { }) if (error) return error.message || 'En feil oppsto når betalingen skulle bekreftes.' + + return null }, confirmSetup: async (clientSecret: string) => { if (!stripe || !elements) return 'Stripe ikke initialisert enda.' @@ -48,6 +52,8 @@ export default function StripePayment({ ref }: Props) { }) if (error) return error.message || 'En feil oppsto ved lagring av informasjon.' + + return null } })) diff --git a/src/app/api/stripe-event/route.ts b/src/app/api/stripe-event/route.ts index 990223710..cdd4236ad 100644 --- a/src/app/api/stripe-event/route.ts +++ b/src/app/api/stripe-event/route.ts @@ -1,6 +1,6 @@ import logger from '@/lib/logger' import { stripe } from '@/lib/stripe' -import { DepositMethods } from '@/services/ledger/transactions/deposits/methods' +import { stripeWebhookCallback } from '@/services/ledger/payments/stripeWebhookCallback' export async function POST(req: Request) { if (!process.env.STRIPE_WEBHOOK_SECRET) { @@ -28,12 +28,7 @@ export async function POST(req: Request) { } try { - await DepositMethods.confirmStripe({ - params: { - balanceTransactionId: event.data.object.balance_transaction, - paymentIntentId: event.data.object.payment_intent, - }, - }) + await stripeWebhookCallback(event) } catch { return new Response('Server-side error confirming deposit', { status: 500 }) } diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 58853febf..1bd3033bb 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,5 +1,5 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' -import { getUser } from '@/auth/getUser' +import { getUser } from '@/auth/session/getUser' import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountPaymentMethods from '@/components/Ledger/Accounts/LedgerAccountPaymentMethodsCard' @@ -13,7 +13,9 @@ export default async function Account() { const account = { id: 1 } //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) - const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: account.id })) + // TODO: use balance + // eslint-disable-next-line + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ params: { id: account.id } })) return
diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index 49291f075..e5c08c635 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -1,11 +1,11 @@ import TransactionList from '@/components/Ledger/Transactions/LedgerTransactionList' -import { getUser } from '@/auth/getUser' +// import { getUser } from '@/auth/session/getUser' export default async function Transactions() { - const { user } = await getUser({ - userRequired: true, - shouldRedirect: true, - }) + // const { user } = await getUser({ + // userRequired: true, + // shouldRedirect: true, + // }) const account = { id: 1 } diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx index ee86d6c00..bf21d1a8f 100644 --- a/src/contexts/paging/LedgerAccountPaging.tsx +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -1,24 +1,17 @@ 'use client' -import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { generatePaging } 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< +export const [LedgerAccountPagingContext, LedgerAccountPagingProvider] = generatePaging< 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), +>({ + fetcher: (paging) => readLedgerAccountPageAction({ params: paging }), + getCursor: ({ lastElement }) => ({ id: lastElement.id }) }) - -export default LedgerAccountPagingProvider diff --git a/src/contexts/paging/LedgerTransactionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx index 56e6ae3a7..d36b9b4e2 100644 --- a/src/contexts/paging/LedgerTransactionPaging.tsx +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -1,26 +1,18 @@ 'use client' -import generatePagingProvider, { generatePagingContext } from './PagingGenerator' +import { generatePaging } from './PagingGenerator' import { readLedgerTransactionPageAction } from '@/services/ledger/ledgerTransactions/actions' import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' // TODO: Might be possible to cleanup? Why is size a type??? - export type PageSizeTransactions = 10 -export const LedgerTransactionPagingContext = generatePagingContext< +export const [LedgerTransactionPagingContext, LedgerTransactionPagingProvider] = generatePaging< 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), +>({ + fetcher: (paging) => readLedgerTransactionPageAction({ params: paging }), + getCursor: ({ lastElement }) => ({ id: lastElement.id }), }) - -// 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/prisma/seeder/src/development/seedDevGroups.ts b/src/prisma/seeder/src/development/seedDevGroups.ts index 41eac8823..cb34f9d84 100644 --- a/src/prisma/seeder/src/development/seedDevGroups.ts +++ b/src/prisma/seeder/src/development/seedDevGroups.ts @@ -37,7 +37,7 @@ export default async function seedDevGroups(prisma: PrismaClient) { order: order.order, ledgerAccount: { create: { - name: `Kontoen til Harambes komité`, + name: 'Kontoen til Harambes komité', type: 'GROUP', }, }, diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts index 70f449fd4..ebab716ed 100644 --- a/src/services/ledger/ledgerAccount/actions.ts +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -1,7 +1,7 @@ 'use server' -import { LedgerAccountMethods } from './operations' -import { action } from '@/services/action' +import { ledgerAccountOperations } from './operations' +import { makeAction } from '@/services/serverAction' -export const calculateLedgerAccountBalanceAction = action(LedgerAccountMethods.calculateBalance) -export const readLedgerAccountPageAction = action(LedgerAccountMethods.readPage) +export const calculateLedgerAccountBalanceAction = makeAction(ledgerAccountOperations.calculateBalance) +export const readLedgerAccountPageAction = makeAction(ledgerAccountOperations.readPage) diff --git a/src/services/ledger/ledgerAccount/operations.ts b/src/services/ledger/ledgerAccount/operations.ts index f16909d62..2f52389c9 100644 --- a/src/services/ledger/ledgerAccount/operations.ts +++ b/src/services/ledger/ledgerAccount/operations.ts @@ -1,15 +1,15 @@ -import { LedgerAccountSchemas } from './schemas' +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 { defineOperation } from '@/services/serviceOperation' import { z } from 'zod' import { LedgerAccountType } from '@prisma/client' -import type { BalanceRecord } from './types' +import type { LedgerAccount } from '@prisma/client' +import type { Balance, BalanceRecord } from './types' -export namespace LedgerAccountMethods { +export const ledgerAccountOperations = { /** * Creates a new ledger account for given user or group. * @@ -20,10 +20,10 @@ export namespace LedgerAccountMethods { * * @returns The created account. */ - export const create = serviceMethod({ + create: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther - dataSchema: LedgerAccountSchemas.create, - method: async ({ prisma, data }) => { + dataSchema: ledgerAccountSchemas.create, + operation: async ({ prisma, data }): Promise => { const type = data.userId === undefined ? 'GROUP' : 'USER' if (data.userId === undefined && data.groupId === undefined) { @@ -43,7 +43,7 @@ export namespace LedgerAccountMethods { } }) }, - }) + }), /** * Reads details of a ledger account for a given user or group. @@ -57,7 +57,7 @@ export namespace LedgerAccountMethods { * * @returns The account details. */ - export const read = serviceMethod({ + read: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.union([ z.object({ @@ -69,7 +69,7 @@ export namespace LedgerAccountMethods { userId: z.undefined(), }), ]), - method: async ({ prisma, session, params }) => { + operation: async ({ prisma, session, params }): Promise => { const account = await prisma.ledgerAccount.findUnique({ where: { userId: params.userId, @@ -79,11 +79,11 @@ export namespace LedgerAccountMethods { if (account) return account - return create({ session, data: params }) + return ledgerAccountOperations.create({ session, data: params }) }, - }) + }), - export const readPage = serviceMethod({ + readPage: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: readPageInputSchemaObject( z.number(), @@ -94,7 +94,7 @@ export namespace LedgerAccountMethods { accountType: z.nativeEnum(LedgerAccountType).optional(), }), ), - method: async ({ params: { paging }, prisma }) => + operation: async ({ params: { paging }, prisma }) => // TODO: Add balance to each account await prisma.ledgerAccount.findMany({ where: { @@ -107,10 +107,11 @@ export namespace LedgerAccountMethods { ...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. + * 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. * @@ -119,13 +120,13 @@ export namespace LedgerAccountMethods { * * @returns The balances of the ledger accounts. */ - export const calculateBalances = serviceMethod({ + calculateBalances: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ ids: z.number().array(), atTransactionId: z.number().optional(), }), - method: async ({ prisma, params }): Promise => { + operation: async ({ prisma, params }): Promise => { const balanceArray = await prisma.ledgerEntry.groupBy({ by: ['ledgerAccountId'], where: { @@ -180,7 +181,7 @@ export namespace LedgerAccountMethods { return balanceRecord } - }) + }), /** * Calcultates the balance of a single account. Under the hood it simply uses `calculateBalances`. @@ -192,14 +193,14 @@ export namespace LedgerAccountMethods { * * @returns The balances of the ledger accounts. */ - export const calculateBalance = serviceMethod({ + calculateBalance: defineOperation({ 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({ + operation: async ({ params }): Promise => { + const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: [params.id], atTransactionId: params.atTransactionId, @@ -208,5 +209,5 @@ export namespace LedgerAccountMethods { return balances[params.id] } - }) + }), } diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/ledgerAccount/schemas.ts index d394216f2..ea4076bf6 100644 --- a/src/services/ledger/ledgerAccount/schemas.ts +++ b/src/services/ledger/ledgerAccount/schemas.ts @@ -1,22 +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(), - }) +const ledgerAcccountSchema = z.object({ + userId: z.number().optional(), + groupId: z.number().optional(), + payoutAccountNumber: z.string().optional(), +}) - export const create = fields.pick({ +export const ledgerAccountSchemas = { + create: ledgerAcccountSchema.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({ + update: ledgerAcccountSchema.partial().pick({ payoutAccountNumber: true, }) } diff --git a/src/services/ledger/ledgerOperations/actions.ts b/src/services/ledger/ledgerOperations/actions.ts index f09bff60e..1f26917f9 100644 --- a/src/services/ledger/ledgerOperations/actions.ts +++ b/src/services/ledger/ledgerOperations/actions.ts @@ -1,7 +1,7 @@ 'use server' +import { ledgerOperationOperations } from './operations' import { makeAction } from '@/services/serverAction' -import { LedgerOperationMethods } from './operations' -export const createDepositAction = makeAction(LedgerOperationMethods.createDeposit) -export const createPayout = makeAction(LedgerOperationMethods.createPayout) +export const createDepositAction = makeAction(ledgerOperationOperations.createDeposit) +export const createPayout = makeAction(ledgerOperationOperations.createPayout) diff --git a/src/services/ledger/ledgerOperations/operations.ts b/src/services/ledger/ledgerOperations/operations.ts index 3ec3145b1..098dfbe76 100644 --- a/src/services/ledger/ledgerOperations/operations.ts +++ b/src/services/ledger/ledgerOperations/operations.ts @@ -1,7 +1,7 @@ -import { LedgerTransactionMethods } from '../ledgerTransactions/operations' -import { PaymentMethods } from '../payments/operations' +import { ledgerTransactionOperations } from '@/services/ledger/ledgerTransactions/operations' +import { paymentOperations } from '@/services/ledger/payments/operations' import { RequireNothing } from '@/auth/auther/RequireNothing' -import { serviceMethod } from '@/services/serviceMethod' +import { defineOperation } from '@/services/serviceOperation' import { z } from 'zod' import { PaymentProvider } from '@prisma/client' @@ -10,7 +10,7 @@ import { PaymentProvider } from '@prisma/client' // other purposes, such as creating a transaction, it should be done through // `LedgerTransaction`. -export namespace LedgerOperationMethods { +export const ledgerOperationOperations = { /** * Creates a deposit transaction, which is a deposit of funds into the ledger. * @@ -19,7 +19,7 @@ export namespace LedgerOperationMethods { * * @return The created transaction representing the deposit operation. */ - export const createDeposit = serviceMethod({ + createDeposit: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), opensTransaction: true, paramsSchema: z.object({ @@ -27,18 +27,19 @@ export namespace LedgerOperationMethods { provider: z.nativeEnum(PaymentProvider), funds: z.coerce.number().positive(), }), - method: async ({ prisma, params }) => { + operation: async ({ prisma, params }) => { const transaction = await prisma.$transaction(async tx => { - const payment = await PaymentMethods.create({ + const payment = await paymentOperations.create({ params: { provider: params.provider, funds: params.funds, descriptionLong: 'Innskudd', descriptionShort: 'Innskudd', }, + prisma: tx, }) - const transaction = await LedgerTransactionMethods.create({ + return await ledgerTransactionOperations.create({ params: { purpose: 'DEPOSIT', ledgerEntries: [{ @@ -47,20 +48,19 @@ export namespace LedgerOperationMethods { }], paymentId: payment.id, }, + prisma: tx, }) - - return transaction }) if (transaction.payment?.state === 'PENDING') { - transaction.payment = await PaymentMethods.initiate({ + transaction.payment = await paymentOperations.initiate({ params: { paymentId: transaction.payment.id }, }) } return transaction } - }) + }), /** * Creates a payout transaction, which is a withdrawal of funds from the ledger. @@ -71,7 +71,7 @@ export namespace LedgerOperationMethods { * * @returns The created transaction representing the payout operation. */ - export const createPayout = serviceMethod({ + createPayout: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ ledgerAccountId: z.number(), @@ -79,17 +79,18 @@ export namespace LedgerOperationMethods { 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({ + operation: async ({ prisma, params }) => prisma.$transaction(async tx => { + const payment = await paymentOperations.create({ params: { provider: 'MANUAL', descriptionLong: 'Utbetaling', descriptionShort: 'Utbetaling', funds: -params.funds, }, + prisma: tx, }) - const transaction = await LedgerTransactionMethods.create({ + const transaction = await ledgerTransactionOperations.create({ params: { purpose: 'PAYOUT', ledgerEntries: [{ @@ -98,9 +99,10 @@ export namespace LedgerOperationMethods { }], paymentId: payment.id, }, + prisma: tx, }) return transaction }) - }) + }), } diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/ledgerOperations/schemas.ts index 5f7cfc90b..44e4d41f6 100644 --- a/src/services/ledger/ledgerOperations/schemas.ts +++ b/src/services/ledger/ledgerOperations/schemas.ts @@ -1,8 +1,8 @@ import { z } from 'zod' -export namespace LedgerOperationSchemas { - export const createDepositSchema = z.object({ - }) +export const ledgerOperationSchemas = { + createDeposit: z.object({ + }), // export const createPayoutSchema = z.object({ // funds: z.coerce.number().nonnegative(), diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/ledgerTransactions/actions.ts index b40e39068..bce5bd079 100644 --- a/src/services/ledger/ledgerTransactions/actions.ts +++ b/src/services/ledger/ledgerTransactions/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { LedgerTransactionMethods } from './operations' -import { action } from '@/services/action' +import { ledgerTransactionOperations } from './operations' +import { makeAction } from '@/services/serverAction' -export const readLedgerTransactionPageAction = action(LedgerTransactionMethods.readPage) +export const readLedgerTransactionPageAction = makeAction(ledgerTransactionOperations.readPage) diff --git a/src/services/ledger/ledgerTransactions/operations.ts b/src/services/ledger/ledgerTransactions/operations.ts index 267a40fba..713f95b3c 100644 --- a/src/services/ledger/ledgerTransactions/operations.ts +++ b/src/services/ledger/ledgerTransactions/operations.ts @@ -1,25 +1,26 @@ import { calculateCreditFees, calculateDebitFees } from './calculateFees' import { determineTransactionState } from './determineTransactionState' -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/operations' +import { ledgerAccountOperations } from '@/services/ledger/ledgerAccount/operations' 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 { defineOperation } from '@/services/serviceOperation' import { LedgerTransactionPurpose } from '@prisma/client' import { z } from 'zod' +import type { ExpandedLedgerTransaction } from './types' import type { Prisma } from '@prisma/client' -export namespace LedgerTransactionMethods { +export const ledgerTransactionOperations = { /** * Reads a single transaction including its ledger entries, payment and manual transfer (if any). */ - export const read = serviceMethod({ + read: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ id: z.number(), }), - method: async ({ prisma, params }) => { + operation: async ({ prisma, params }) => { const transaction = await prisma.ledgerTransaction.findUniqueOrThrow({ where: { id: params.id, @@ -37,12 +38,12 @@ export namespace LedgerTransactionMethods { return transaction } - }) + }), /** * Read several ledger transactions including its ledger entries, payment and manual transfer (if any). */ - export const readPage = serviceMethod({ + readPage: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: readPageInputSchemaObject( z.number(), @@ -53,7 +54,7 @@ export namespace LedgerTransactionMethods { accountId: z.number(), }), ), - method: async ({ prisma, params }) => prisma.ledgerTransaction.findMany({ + operation: async ({ prisma, params }) => prisma.ledgerTransaction.findMany({ where: { ledgerEntries: { some: { @@ -76,19 +77,19 @@ export namespace LedgerTransactionMethods { ], ...cursorPageingSelection(params.paging.page) }) - }) + }), /** * Tries to advance the transactions state to a terminal state. * Also, updates the fees if possible. */ - export const advance = serviceMethod({ + advance: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), paramsSchema: z.object({ id: z.number(), }), - method: async ({ prisma, params }) => { - let transaction = await read({ + operation: async ({ prisma, params }) => { + let transaction: ExpandedLedgerTransaction = await ledgerTransactionOperations.read({ params: { id: params.id }, }) @@ -122,7 +123,7 @@ export namespace LedgerTransactionMethods { }) } - const balances = await LedgerAccountMethods.calculateBalances({ + const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), atTransactionId: transaction.id, @@ -141,13 +142,13 @@ export namespace LedgerTransactionMethods { data: transition, }) - transaction = await read({ + transaction = await ledgerTransactionOperations.read({ params: { id: params.id }, }) return transaction } - }) + }), /** * Create a new transaction on the ledger with the given entries and optionally @@ -157,7 +158,7 @@ export namespace LedgerTransactionMethods { * * The lifecycle of the transaction is automatically handled by the system. */ - export const create = serviceMethod({ + create: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO, paramsSchema: z.object({ purpose: z.nativeEnum(LedgerTransactionPurpose), @@ -167,10 +168,10 @@ export namespace LedgerTransactionMethods { }).array(), paymentId: z.number().optional(), }), - method: async ({ prisma, params },) => { + operation: 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({ + const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, }) @@ -205,7 +206,7 @@ export namespace LedgerTransactionMethods { }, }) - const transaction = await advance({ + const transaction: ExpandedLedgerTransaction = await ledgerTransactionOperations.advance({ params: { id, }, @@ -218,5 +219,5 @@ export namespace LedgerTransactionMethods { return transaction } - }) + }), } diff --git a/src/services/ledger/payments/operations.ts b/src/services/ledger/payments/operations.ts index a0c745fdd..a1e5bb5c3 100644 --- a/src/services/ledger/payments/operations.ts +++ b/src/services/ledger/payments/operations.ts @@ -1,18 +1,18 @@ import { RequireNothing } from '@/auth/auther/RequireNothing' import { stripe } from '@/lib/stripe' import { ServerError } from '@/services/error' -import { serviceMethod } from '@/services/serviceMethod' +import { defineOperation } from '@/services/serviceOperation' import { PaymentProvider } from '@prisma/client' import { z } from 'zod' -export namespace PaymentMethods { +export const paymentOperations = { /** * 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({ + create: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.intersection( z.object({ @@ -35,7 +35,7 @@ export namespace PaymentMethods { }), ]), ), - method: async ({ prisma, params }) => { + operation: async ({ prisma, params }) => { const { details = {}, ...paymentData } = params return prisma.payment.create({ @@ -45,10 +45,10 @@ export namespace PaymentMethods { state: params.provider === 'MANUAL' ? 'SUCCEEDED' : 'PENDING', fees: params.provider === 'MANUAL' ? 0 : undefined, stripePayment: params.provider === 'STRIPE' ? { - create: params.details, + create: details, } : undefined, manualPayment: params.provider === 'MANUAL' ? { - create: params.details, + create: details, } : undefined, }, include: { @@ -57,14 +57,14 @@ export namespace PaymentMethods { } }) }, - }) + }), /** * 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({ + initiate: defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther paramsSchema: z.object({ paymentId: z.number(), @@ -72,7 +72,7 @@ export namespace PaymentMethods { // 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 }) => { + operation: async ({ prisma, params }) => { const payment = await prisma.payment.findUniqueOrThrow({ where: { id: params.paymentId, @@ -90,54 +90,54 @@ export namespace PaymentMethods { 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.') + if (payment.provider === '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 (payment.provider === '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.') - } + 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, - }, + 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.') + state: 'PROCESSING', + }, + include: { + stripePayment: true, + manualPayment: true, + } + }) } + + // If we reach here, the payment provider is unknown. + throw new ServerError('SERVER ERROR', 'Prøvde å forespørre betalingsleverandør som ikke er støttet.') }, - }) + }), } diff --git a/src/services/stripeCustomers/actions.ts b/src/services/stripeCustomers/actions.ts index d1f35680e..f77b2621e 100644 --- a/src/services/stripeCustomers/actions.ts +++ b/src/services/stripeCustomers/actions.ts @@ -1,6 +1,6 @@ 'use server' -import { makeAction } from "@/services/serverAction" -import { StripeCustomerMethods } from "./operations" +import { stripeCustomerOperations } from './operations' +import { makeAction } from '@/services/serverAction' -export const createStripeCustomerSessionAction = makeAction(StripeCustomerMethods.createSession) +export const createStripeCustomerSessionAction = makeAction(stripeCustomerOperations.createSession) diff --git a/src/services/stripeCustomers/operations.ts b/src/services/stripeCustomers/operations.ts index 9ce1a151c..774668c46 100644 --- a/src/services/stripeCustomers/operations.ts +++ b/src/services/stripeCustomers/operations.ts @@ -1,21 +1,21 @@ +import { ServerError } from '@/services/error' +import { defineOperation } from '@/services/serviceOperation' import { stripe } from '@/lib/stripe' -import { serviceMethod } from '@/services/serviceMethod' -import { z } from 'zod' -import { ServerError } from '../error' import { RequireUserId } from '@/auth/auther/RequireUserId' +import { z } from 'zod' -export namespace StripeCustomerMethods { +export const stripeCustomerOperations = { /** * 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({ + readOrCreate: defineOperation({ // 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 }) => { + operation: 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. @@ -63,7 +63,7 @@ export namespace StripeCustomerMethods { } // Otherwise, we can just return the existing customer. - // But, we'll first verify that it is not deleted and that + // 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) @@ -84,28 +84,27 @@ export namespace StripeCustomerMethods { 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({ + createSession: defineOperation({ authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), paramsSchema: z.object({ userId: z.number(), }), - method: async ({ params: { userId } }) => { - const { customerId } = await readOrCreate({ params: { userId } }) + operation: async ({ params: { userId } }) => { + const { customerId } = await stripeCustomerOperations.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: @@ -136,11 +135,11 @@ export namespace StripeCustomerMethods { }) // 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/tests/services/context.test.ts b/tests/services/context.test.ts index 9acc96850..aec23be23 100644 --- a/tests/services/context.test.ts +++ b/tests/services/context.test.ts @@ -1,21 +1,21 @@ import { RequireNothing } from '@/auth/auther/RequireNothing' -import { Session } from '@/auth/Session' -import { serviceMethod } from '@/services/serviceMethod' import { prisma as globalPrisma } from '@/prisma/client' +import { defineOperation } from '@/services/serviceOperation' +import { Session } from '@/auth/session/Session' import { describe, test, expect } from '@jest/globals' -import type { ServiceMethodContext } from '@/services/serviceMethod' +import type { ServiceOperationContext } from '@/services/serviceOperation' -const returnContextInfo = serviceMethod({ +const returnContextInfo = defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), - method: async ({ prisma, session }) => ({ + operation: async ({ prisma, session }) => ({ inTransaction: '$transaction' in prisma, apiKeyId: session.apiKeyId, }) }) -const callReturnContextInfo = serviceMethod({ +const callReturnContextInfo = defineOperation({ authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), - method: async () => returnContextInfo({}) + operation: async () => returnContextInfo({}) }) describe('context', () => { @@ -27,7 +27,7 @@ describe('context', () => { }) const emptySession = Session.empty() - const contexts: ServiceMethodContext[] = [ + const contexts: ServiceOperationContext[] = [ { session: emptySession, prisma: globalPrisma, bypassAuth: false }, { session: apiKeySession, prisma: globalPrisma, bypassAuth: false }, { session: emptySession, prisma: globalPrisma, bypassAuth: true }, diff --git a/tests/services/ledger/ledgerAccounts.test.ts b/tests/services/ledger/ledgerAccounts.test.ts index 537492497..0efd6e6a8 100644 --- a/tests/services/ledger/ledgerAccounts.test.ts +++ b/tests/services/ledger/ledgerAccounts.test.ts @@ -1,9 +1,9 @@ import { describe, test } from '@jest/globals' describe('ledger accounts', () => { - const testEntries = [ - [100_00, [{ amount: 100_00, fees: 10_00 }]], - ] + // 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 index 8fd9847b9..813df27ca 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -1,9 +1,9 @@ -import { LedgerAccountMethods } from '@/services/ledger/ledgerAccount/operations' -import { LedgerTransactionMethods } from '@/services/ledger/ledgerTransactions/operations' -import { PaymentMethods } from '@/services/ledger/payments/operations' -import { UserMethods } from '@/services/users/methods' import { allSettledOrThrow } from 'tests/utils' import { prisma } from '@/prisma/client' +import { ledgerAccountOperations } from '@/services/ledger/ledgerAccount/operations' +import { userOperations } from '@/services/users/operations' +import { paymentOperations } from '@/services/ledger/payments/operations' +import { ledgerTransactionOperations } from '@/services/ledger/ledgerTransactions/operations' import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' const TEST_ACCOUNT_COUNT = 3 @@ -18,7 +18,7 @@ describe('ledger transactions', () => { await allSettledOrThrow(Array.from({ length: TEST_ACCOUNT_COUNT }).map(async (_, i) => { const username = `testuser${i + 1}` - const testUser = await UserMethods.create({ + const testUser = await userOperations.create({ data: { email: `${username}@example.com`, firstname: 'Test', @@ -28,7 +28,7 @@ describe('ledger transactions', () => { bypassAuth: true, }) - const testAccount = await LedgerAccountMethods.create({ + const testAccount = await ledgerAccountOperations.create({ data: { userId: testUser.id, }, @@ -51,7 +51,7 @@ describe('ledger transactions', () => { describe('internal transactions', () => { beforeEach(async () => { await allSettledOrThrow(testAccountIds.map(async accountId => { - const manualPayment = await PaymentMethods.create({ + const manualPayment = await paymentOperations.create({ params: { funds: INITIAL_BALANCE.amount, provider: 'MANUAL', @@ -61,7 +61,7 @@ describe('ledger transactions', () => { }, }) - await LedgerTransactionMethods.create({ + await ledgerTransactionOperations.create({ params: { purpose: 'DEPOSIT', ledgerEntries: [{ @@ -87,7 +87,7 @@ describe('ledger transactions', () => { ] test.each(validLedgerEntries)('valid internal transactions', async (...entries) => { - const transaction = await LedgerTransactionMethods.create({ + const transaction = await ledgerTransactionOperations.create({ params: { ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), purpose: 'DEPOSIT', @@ -98,7 +98,7 @@ describe('ledger transactions', () => { state: 'SUCCEEDED', }) - const balances = await LedgerAccountMethods.calculateBalances({ + const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: testAccountIds }, }) @@ -121,7 +121,7 @@ describe('ledger transactions', () => { ] test.each(invalidLedgerEntries)('invalid internal transactions', async (...entries) => { - const transactionPromise = LedgerTransactionMethods.create({ + const transactionPromise = ledgerTransactionOperations.create({ params: { ledgerEntries: entries.map((funds, i) => ({ funds, ledgerAccountId: testAccountIds[i] })), purpose: 'DEPOSIT', @@ -130,7 +130,7 @@ describe('ledger transactions', () => { await expect(transactionPromise).rejects.toThrow() - const balances = await LedgerAccountMethods.calculateBalances({ + const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: testAccountIds }, }) From ff3292ddb1293b37ff144b602db503fee3ffa29e Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 6 Jan 2026 21:38:17 +0100 Subject: [PATCH 43/62] fix: build errors --- .../Ledger/Accounts/LedgerAccountBalance.tsx | 2 +- src/lib/stripe.ts | 10 +++++++--- src/prisma/schema/user.prisma | 17 +++++++++-------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx index 1bebcfaa2..9129dd394 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx @@ -9,7 +9,7 @@ type Props = { } export default async function LedgerAccountBalance({ ledgerAccountId: accountId, showFees }: Props) { - const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ id: accountId })) + const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ params: { id: accountId } })) return
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 814589894..79c2b2229 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -1,7 +1,11 @@ +import { isBuildPhase } from './isBuildPhase' import Stripe from 'stripe' -if (!process.env.STRIPE_SECRET_KEY) { - throw new Error('Stripe secret key not set') +// Stripe can only be initialized with a secret key on the server side. +// During the build phase, the stripe key might not be set. +// To avoid build-time errors, we skip the check during the build phase. +if (!process.env.STRIPE_SECRET_KEY && !isBuildPhase()) { + throw new Error('Stripe secret key not set.') } -export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { telemetry: false }) +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'fake-key', { telemetry: false }) diff --git a/src/prisma/schema/user.prisma b/src/prisma/schema/user.prisma index 19389b4b2..c8bb66b5f 100644 --- a/src/prisma/schema/user.prisma +++ b/src/prisma/schema/user.prisma @@ -13,6 +13,7 @@ model User { bio String @default("") archived Boolean @default(false) acceptedTerms DateTime? + imageConsent Boolean @default(false) //if the user has consented to being photographed sex SEX? allergies String? mobile String? @@ -20,9 +21,9 @@ model User { 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 + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // is also updated manually // Authentication info used for logging in. credentials Credentials? @@ -35,7 +36,7 @@ model User { LockerReservation LockerReservation[] // Omega quotes posted by the user. - omegaQuote OmegaQuote[] + omegaQuote OmegaQuote[] // Which ledger account (i.e. internal bank account) and // stripe customer this user is associated with. @@ -66,7 +67,7 @@ model User { // The queue used to determine who is registering cards at Kiogeskabet. registerStudentCardQueue RegisterStudentCardQueue[] - + // Which cabin bookings the user has made. cabinBooking Booking[] @relation() @@ -76,7 +77,7 @@ model User { } // 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.. +// 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 @@ -103,8 +104,8 @@ model FeideAccount { // Associates each user with their Stripe customer id. model StripeCustomer { - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) - userId Int @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId Int @id customerId String @unique createdAt DateTime @default(now()) From 157008ed4638f5e1b17da749f6e689e628d2418d Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 7 Jan 2026 00:23:17 +0100 Subject: [PATCH 44/62] chore: fix merge conflic errors in package lock --- package-lock.json | 514 +++++++++++++++++++++++++++++++--------------- 1 file changed, 351 insertions(+), 163 deletions(-) diff --git a/package-lock.json b/package-lock.json index 966f843fd..637918799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,16 +162,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "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==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" }, "engines": { @@ -267,16 +267,6 @@ "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", @@ -423,13 +413,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", + "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -746,14 +736,14 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", + "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", + "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1" }, "engines": { @@ -761,28 +751,38 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@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" + "@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" }, "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.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -858,9 +858,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", + "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", "cpu": [ "ppc64" ], @@ -875,9 +875,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", - "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", + "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", "cpu": [ "arm" ], @@ -892,9 +892,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", - "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", + "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", "cpu": [ "arm64" ], @@ -909,9 +909,9 @@ } }, "node_modules/@esbuild/android-x64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", + "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", "cpu": [ "x64" ], @@ -926,9 +926,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", - "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", + "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", "cpu": [ "arm64" ], @@ -943,9 +943,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", - "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", + "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", "cpu": [ "x64" ], @@ -960,9 +960,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", + "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", "cpu": [ "arm64" ], @@ -977,9 +977,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", - "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", + "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", "cpu": [ "x64" ], @@ -994,9 +994,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", - "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", + "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", "cpu": [ "arm" ], @@ -1011,9 +1011,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", - "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", + "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", "cpu": [ "arm64" ], @@ -1028,9 +1028,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", - "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", + "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", "cpu": [ "ia32" ], @@ -1045,9 +1045,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", + "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", "cpu": [ "loong64" ], @@ -1062,9 +1062,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", - "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", + "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", "cpu": [ "mips64el" ], @@ -1079,9 +1079,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", - "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", + "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", "cpu": [ "ppc64" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", + "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", "cpu": [ "riscv64" ], @@ -1113,9 +1113,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", + "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", "cpu": [ "s390x" ], @@ -1130,9 +1130,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", - "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", + "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", "cpu": [ "x64" ], @@ -1147,9 +1147,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", - "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", + "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", "cpu": [ "arm64" ], @@ -1164,9 +1164,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", + "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", "cpu": [ "x64" ], @@ -1181,9 +1181,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", + "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", "cpu": [ "arm64" ], @@ -1198,9 +1198,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", - "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", + "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", "cpu": [ "x64" ], @@ -1215,9 +1215,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", - "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", + "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", "cpu": [ "x64" ], @@ -1232,9 +1232,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", - "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", + "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==", "cpu": [ "arm64" ], @@ -1249,9 +1249,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", - "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", + "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", "cpu": [ "ia32" ], @@ -1266,9 +1266,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "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==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", + "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", "cpu": [ "x64" ], @@ -2388,14 +2388,18 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "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==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2408,6 +2412,16 @@ "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", @@ -2416,9 +2430,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2719,6 +2733,139 @@ "@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", @@ -2757,6 +2904,63 @@ "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" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz", + "integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "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==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@parcel/watcher/node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -4462,7 +4666,6 @@ "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" @@ -4475,7 +4678,6 @@ "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" @@ -5467,7 +5669,6 @@ "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" } @@ -5529,7 +5730,6 @@ "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" }, @@ -5591,31 +5791,31 @@ "node": ">=18" }, "optionalDependencies": { - "@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" + "@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" } }, "node_modules/escalade": { @@ -6373,7 +6573,6 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -6463,7 +6662,6 @@ "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", @@ -6497,7 +6695,6 @@ "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" @@ -6632,7 +6829,6 @@ "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" }, @@ -6720,7 +6916,6 @@ "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" }, @@ -10001,7 +10196,6 @@ "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" }, @@ -10694,10 +10888,9 @@ } }, "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", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dependencies": { "side-channel": "^1.1.0" }, @@ -11328,7 +11521,6 @@ "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": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -11347,7 +11539,6 @@ "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" @@ -11363,7 +11554,6 @@ "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", @@ -11381,7 +11571,6 @@ "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", @@ -11773,7 +11962,6 @@ "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" From 1f14fa40c0d55303794f2d629580883546d51030 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 7 Jan 2026 00:23:24 +0100 Subject: [PATCH 45/62] feat: freezing of accounts --- .../Accounts/LedgerAccountFreezeButton.tsx | 27 +++++++ ... => LedgerAccountOverviewCard.module.scss} | 8 ++ .../Accounts/LedgerAccountOverviewCard.tsx | 25 +++++-- .../[username]/(user-admin)/account/page.tsx | 12 +-- src/prisma/schema/ledger.prisma | 1 + src/services/ledger/ledgerAccount/actions.ts | 3 + .../ledger/ledgerAccount/operations.ts | 25 +++++++ src/services/ledger/ledgerAccount/schemas.ts | 3 + .../determineTransactionState.ts | 74 +++++++++++-------- .../ledger/ledgerTransactions/operations.ts | 22 +++++- 10 files changed, 150 insertions(+), 50 deletions(-) create mode 100644 src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx rename src/app/_components/Ledger/Accounts/{LedgerAccountOverview.module.scss => LedgerAccountOverviewCard.module.scss} (65%) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx new file mode 100644 index 000000000..f58838444 --- /dev/null +++ b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx @@ -0,0 +1,27 @@ +'use client' + +import Button from '@/components/UI/Button' +import { updateLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import { LedgerAccount } from '@prisma/client' +import { useRouter } from 'next/navigation'; + +export default function LedgerAccountFreezeButton({ ledgerAccount, className }: { ledgerAccount: LedgerAccount, className?: string }) { + const { refresh } = useRouter(); + + const toggleFrozen = async () => { + await updateLedgerAccountAction({ + params: { + id: ledgerAccount.id, + }, + }, { + data: { + frozen: !ledgerAccount.frozen, + } + }) + refresh() + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss similarity index 65% rename from src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss rename to src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss index 178266988..1b6b2bbc8 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverview.module.scss +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.module.scss @@ -1,5 +1,13 @@ @use "@/styles/ohma"; +.frozenStatus { + color: red; + + .frozenWarningHidden { + visibility: hidden; + } +} + .ledgerAccountOverviewButtons { margin-top: 3*ohma.$gap; display: flex; diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 2d8fad4eb..0f72c5471 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -1,14 +1,18 @@ -import styles from './LedgerAccountOverview.module.scss' +import styles from './LedgerAccountOverviewCard.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/session/getUser' import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faWarning } from '@fortawesome/free-solid-svg-icons' +import { LedgerAccount } from '@prisma/client' +import { updateLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import LedgerAccountFreezeButton from './LedgerAccountFreezeButton' type Props = { - ledgerAccountId: number, + ledgerAccount: LedgerAccount, showFees?: boolean, showDepositButton?: boolean, showPayoutButton?: boolean, @@ -31,7 +35,7 @@ const getCustomerSessionClientSecret = async () => { export default async function LedgerAccountOverview({ showFees, - ledgerAccountId, + ledgerAccount, showPayoutButton, showDepositButton, showDeactivateButton, @@ -41,14 +45,19 @@ export default async function LedgerAccountOverview({ : undefined return - + +
+ { +

Kontoen er fryst; Ingen transaksjoner kan utføres.

+ } +
{ showDepositButton && - + } - { showPayoutButton && } - { showDeactivateButton && } + { showPayoutButton && } + { showDeactivateButton && }
} diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 1bd3033bb..de46d3d49 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,6 +1,6 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/session/getUser' -import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' +import { calculateLedgerAccountBalanceAction, readLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountPaymentMethods from '@/components/Ledger/Accounts/LedgerAccountPaymentMethodsCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' @@ -11,15 +11,11 @@ export default async function Account() { shouldRedirect: true, }) // TODO: Replace with whatever we agree should be the standard for getting user - const account = { id: 1 } //unwrapActionReturn(await readLedgerAccount({ userId: session.user.id })) - - // TODO: use balance - // eslint-disable-next-line - const balance = unwrapActionReturn(await calculateLedgerAccountBalanceAction({ params: { id: account.id } })) + const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { userId: session.user.id } })) return
- + - +
} diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index b51666022..390c927a8 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -38,6 +38,7 @@ model LedgerAccount { group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull, onUpdate: Cascade) groupId Int? @unique type LedgerAccountType + frozen Boolean @default(false) // If true, no transactions may be made on this account. name String? // Optional display name for the account, only used for group accounts payoutAccountNumber String? // For display only, only used for group accounts diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/ledgerAccount/actions.ts index ebab716ed..a3c4aff6a 100644 --- a/src/services/ledger/ledgerAccount/actions.ts +++ b/src/services/ledger/ledgerAccount/actions.ts @@ -4,4 +4,7 @@ import { ledgerAccountOperations } from './operations' import { makeAction } from '@/services/serverAction' export const calculateLedgerAccountBalanceAction = makeAction(ledgerAccountOperations.calculateBalance) +export const readLedgerAccountAction = makeAction(ledgerAccountOperations.read) export const readLedgerAccountPageAction = makeAction(ledgerAccountOperations.readPage) +export const updateLedgerAccountAction = makeAction(ledgerAccountOperations.update) + diff --git a/src/services/ledger/ledgerAccount/operations.ts b/src/services/ledger/ledgerAccount/operations.ts index 2f52389c9..9dda0cc29 100644 --- a/src/services/ledger/ledgerAccount/operations.ts +++ b/src/services/ledger/ledgerAccount/operations.ts @@ -39,6 +39,7 @@ export const ledgerAccountOperations = { userId: data.userId, groupId: data.groupId, payoutAccountNumber: data.payoutAccountNumber, + frozen: data.frozen, type, } }) @@ -109,6 +110,30 @@ export const ledgerAccountOperations = { }), + /** + * Updates a ledger account with the given data. + * + * @param params.id The ID of the account to update. + * @param data The data to update the account with. + * + * @returns The updated account. + */ + update: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: Add proper auther + paramsSchema: z.object({ + id: z.number(), + }), + dataSchema: ledgerAccountSchemas.update, + operation: async ({ prisma, params, data }) => { + return prisma.ledgerAccount.update({ + where: { + id: params.id, + }, + data, + }) + } + }), + /** * Calculates the balance and fees of a ledger account. * Optionally takes a transaction ID to calculate the balance up until that transaction. diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/ledgerAccount/schemas.ts index ea4076bf6..a68540cd9 100644 --- a/src/services/ledger/ledgerAccount/schemas.ts +++ b/src/services/ledger/ledgerAccount/schemas.ts @@ -4,6 +4,7 @@ const ledgerAcccountSchema = z.object({ userId: z.number().optional(), groupId: z.number().optional(), payoutAccountNumber: z.string().optional(), + frozen: z.boolean().optional(), }) export const ledgerAccountSchemas = { @@ -11,6 +12,7 @@ export const ledgerAccountSchemas = { userId: true, groupId: true, payoutAccountNumber: true, + frozen: true, }).refine( data => (data.userId === undefined) !== (data.groupId === undefined), 'Bruker- eller gruppe-ID må være satt.' @@ -18,5 +20,6 @@ export const ledgerAccountSchemas = { update: ledgerAcccountSchema.partial().pick({ payoutAccountNumber: true, + frozen: true, }) } diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/ledgerTransactions/determineTransactionState.ts index 62a938135..e8b8eef38 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/ledgerTransactions/determineTransactionState.ts @@ -1,30 +1,33 @@ import type { ExpandedLedgerTransaction } from './types' import type { BalanceRecord } from '@/services/ledger/ledgerAccount/types' -import type { LedgerTransactionState, PaymentState } from '@prisma/client' +import type { LedgerAccount, LedgerTransactionState, PaymentState } from '@prisma/client' type LedgerTransactionTransition = { state: LedgerTransactionState, reason?: string, } -type LedgerTransactionRule = ( +type LedgerTransactionRuleContext = { transaction: ExpandedLedgerTransaction, balances: BalanceRecord, -) => LedgerTransactionTransition | null + frozenAccountIds: Set, +} + +type LedgerTransactionRule = (context: LedgerTransactionRuleContext) => LedgerTransactionTransition | null /** * Determines the state of a given transaction. */ export async function determineTransactionState( - transaction: ExpandedLedgerTransaction, - balances: BalanceRecord, + context: LedgerTransactionRuleContext ): 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, + noFrozenAccounts, + noFailedPayment, amountAndFeesHaveSameSigns, validAmountSum, sufficientBalances, @@ -34,7 +37,7 @@ export async function determineTransactionState( ] for (const rule of rules) { - const state = rule(transaction, balances) + const state = rule(context) if (state) return state } @@ -47,9 +50,19 @@ export async function determineTransactionState( * can never change state. */ function noTerminalState( - { state }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext +): LedgerTransactionTransition | null { + if (transaction.state !== 'PENDING') return { state: transaction.state } + + return null +} + +function noFrozenAccounts( + { transaction, frozenAccountIds }: LedgerTransactionRuleContext ): LedgerTransactionTransition | null { - if (state !== 'PENDING') return { state } + const hasFrozenAccount = transaction.ledgerEntries.some(entry => frozenAccountIds.has(entry.ledgerAccountId)) + + if (hasFrozenAccount) return { state: 'FAILED', reason: 'En eller flere kontoer er frossene.' } return null } @@ -57,13 +70,13 @@ function noTerminalState( /** * If any payment has failed, the entire transaction has failed. */ -function noFailedTransfer( - { payment }: ExpandedLedgerTransaction +function noFailedPayment( + { transaction }: LedgerTransactionRuleContext ): LedgerTransactionTransition | null { const okStates: PaymentState[] = ['PENDING', 'PROCESSING', 'SUCCEEDED'] - const hasFailedTransfer = payment && !okStates.includes(payment.state) + const hasFailedPayment = transaction.payment && !okStates.includes(transaction.payment.state) - if (hasFailedTransfer) return { state: 'FAILED', reason: 'Betaling mislyktes.' } + if (hasFailedPayment) return { state: 'FAILED', reason: 'Betaling mislyktes.' } return null } @@ -71,17 +84,17 @@ function noFailedTransfer( /** * Check that ledger entries, payment and manual transfer have correct signs. * - * Mathematically: `amount > 0 <=> fees > 0` and `amount < 0 <=> fees < 0`. + * Mathematically: `amount >= 0 <=> fees >= 0` and `amount <= 0 <=> fees <= 0`. */ function amountAndFeesHaveSameSigns( - { ledgerEntries, payment }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext ): 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) + const validEntries = transaction.ledgerEntries.every(entry => sameSigns(entry.funds, entry.fees)) + const validTransfer = !transaction.payment || sameSigns(transaction.payment.funds, transaction.payment.fees) if (!validEntries || !validTransfer) return { state: 'FAILED', reason: 'Ugyldige beløp og/eller gebyrer.' } @@ -93,12 +106,12 @@ function amountAndFeesHaveSameSigns( * I.e. money must come from somewhere and go to somewhere. */ function validAmountSum( - { ledgerEntries, payment }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext ): 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 + const totalLedgerEntryFunds = transaction.ledgerEntries.reduce((sum, entry) => sum + entry.funds, 0) + const paymentFunds = transaction.payment?.funds ?? 0 if (totalLedgerEntryFunds !== paymentFunds) return { state: 'FAILED', reason: 'Ugyldig totalbeløp.' } @@ -110,10 +123,9 @@ function validAmountSum( * have a positive balance after the transaction succeeds. */ function sufficientBalances( - { ledgerEntries }: ExpandedLedgerTransaction, - balances: BalanceRecord + { transaction, balances }: LedgerTransactionRuleContext, ): LedgerTransactionTransition | null { - const debitLedgerAccountIds = ledgerEntries.filter(entry => entry.funds < 0).map(entry => entry.ledgerAccountId) + const debitLedgerAccountIds = transaction.ledgerEntries.filter(entry => entry.funds < 0).map(entry => entry.ledgerAccountId) const debitBalances = debitLedgerAccountIds.map(id => balances[id]) if (debitBalances.some(balance => !balance)) { @@ -131,11 +143,11 @@ function sufficientBalances( * If any payment is pending, the transaction is pending. */ function transfersComplete( - { payment }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext ): 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' + const hasPendingTransfer = transaction.payment && transaction.payment.state !== 'SUCCEEDED' if (hasPendingTransfer) return { state: 'PENDING' } @@ -146,11 +158,11 @@ function transfersComplete( * All fees must be non-null. */ function noNullFees( - { ledgerEntries, payment }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext ): LedgerTransactionTransition | null { const hasNullFees = - ledgerEntries.some(entry => entry.fees === null) || - payment?.fees === null + transaction.ledgerEntries.some(entry => entry.fees === null) || + transaction.payment?.fees === null if (hasNullFees) return { state: 'FAILED', reason: 'Manglende gebyrer.' } @@ -161,12 +173,12 @@ function noNullFees( * Fees must also follow Kirchhoff's first law. */ function validFeesSum( - { ledgerEntries, payment }: ExpandedLedgerTransaction + { transaction }: LedgerTransactionRuleContext ): 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 + const totalLedgerEntryFees = transaction.ledgerEntries.reduce((sum, entry) => sum + entry.fees!, 0) + const paymentFees = transaction.payment?.fees ?? 0 if (totalLedgerEntryFees !== paymentFees) return { state: 'FAILED', reason: 'Ugyldig sum av gebyrer.' } diff --git a/src/services/ledger/ledgerTransactions/operations.ts b/src/services/ledger/ledgerTransactions/operations.ts index 713f95b3c..e8a708db9 100644 --- a/src/services/ledger/ledgerTransactions/operations.ts +++ b/src/services/ledger/ledgerTransactions/operations.ts @@ -95,6 +95,9 @@ export const ledgerTransactionOperations = { const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) + // Update credit fees if they could be calculated. + // Credit fees are null while the payment is pending, since + // the final fees are unknown until the payment is completed. if (creditFees) { const creditEntries = transaction.ledgerEntries.filter(entry => entry.funds > 0) @@ -107,9 +110,11 @@ export const ledgerTransactionOperations = { }, })) satisfies Prisma.LedgerEntryUpdateWithWhereUniqueWithoutLedgerTransactionInput[] // X_x + // TODO: Figure out a way to not throw here. await prisma.ledgerTransaction.update({ where: { id: params.id, + state: 'PENDING', // Protect against modifying a completed transaction. }, data: { ledgerEntries: { @@ -117,7 +122,7 @@ export const ledgerTransactionOperations = { }, }, }) - + transaction.ledgerEntries.forEach(entry => { entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees }) @@ -130,7 +135,18 @@ export const ledgerTransactionOperations = { }, }) - const transition = await determineTransactionState(transaction, balances) + // Find frozen accounts, if any, among the involved ledger accounts. + const frozenAccounts = await prisma.ledgerAccount.findMany({ + where: { + id: { + in: transaction.ledgerEntries.map(entry => entry.ledgerAccountId), + }, + frozen: true, + } + }) + const frozenAccountIds = new Set(frozenAccounts.map(account => account.id)) + + const transition = await determineTransactionState({ transaction, balances, frozenAccountIds }) // We use `updateMany` in stead of just `update` here because // we don't want to throw in case the record is not found. @@ -169,7 +185,7 @@ export const ledgerTransactionOperations = { paymentId: z.number().optional(), }), operation: async ({ prisma, params }) => { - // Calculate the balance for all accounts which are going to be deducted + // Calculate the balance for all accounts which are going to be deducted. const debitEntries = params.ledgerEntries.filter(entry => entry.funds < 0) const balances = await ledgerAccountOperations.calculateBalances({ params: { ids: debitEntries.map(entry => entry.ledgerAccountId) }, From dcb1f1acd99af33a9832428729f4ba73360d8af4 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 12 Jan 2026 09:48:56 +0100 Subject: [PATCH 46/62] feat: removal of card --- .../LedgerAccountPaymentMethodsCard.tsx | 28 +++--- .../LedgerAccountTransactionSummaryCard.tsx | 10 -- .../Ledger/Modals/BankCardModal.tsx | 27 ------ .../Modals/PaymentMethodList.module.scss | 24 +++++ .../Ledger/Modals/PaymentMethodList.tsx | 47 ++++++++++ ...le.scss => PaymentMethodModal.module.scss} | 0 .../Ledger/Modals/PaymentMethodModal.tsx | 56 ++++++++++++ .../account/transactions/page.tsx | 2 +- .../ledger/ledgerTransactions/operations.ts | 40 ++++---- src/services/stripeCustomers/actions.ts | 3 + src/services/stripeCustomers/operations.ts | 91 ++++++++++++++++++- src/services/stripeCustomers/types.ts | 7 ++ 12 files changed, 259 insertions(+), 76 deletions(-) delete mode 100644 src/app/_components/Ledger/Modals/BankCardModal.tsx create mode 100644 src/app/_components/Ledger/Modals/PaymentMethodList.module.scss create mode 100644 src/app/_components/Ledger/Modals/PaymentMethodList.tsx rename src/app/_components/Ledger/Modals/{BankCardModal.module.scss => PaymentMethodModal.module.scss} (100%) create mode 100644 src/app/_components/Ledger/Modals/PaymentMethodModal.tsx create mode 100644 src/services/stripeCustomers/types.ts diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index 2713a64b4..430dafa12 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -1,28 +1,23 @@ -import BankCardModal from '@/components/Ledger/Modals/BankCardModal' +import PaymentMethodModal from '@/components/Ledger/Modals/PaymentMethodModal' import Card from '@/components/UI/Card' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { readUserAction } from '@/services/users/actions' import BooleanIndicator from '@/components/UI/BooleanIndicator' -import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' +import { readSavedPaymentMethodsAction } from '@/services/stripeCustomers/actions' import Link from 'next/link' +import PaymentMethodList from '../Modals/PaymentMethodList' +import { faArrowRight } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' type Props = { userId: number, } -const getCustomerSessionClientSecret = async (userId: number) => { - const customerSessionResult = await createStripeCustomerSessionAction({ params: { userId } }) - if (customerSessionResult.success) { - return customerSessionResult.data.customerSessionClientSecret - } - return undefined -} - export default async function LedgerAccountPaymentMethods({ userId }: Props) { - const user = unwrapActionReturn(await readUserAction({ params: { id: userId } })) - const customerSessionClientSecret = await getCustomerSessionClientSecret(userId) + const user = unwrapActionReturn(await readUserAction({ params: { id: userId } })) // TODO: Change to better method + const savedPaymentMethods = unwrapActionReturn(await readSavedPaymentMethodsAction({ params: { userId } })) - const hasBankCard = false // TODO: Actually check with Stripe + const hasBankCard = savedPaymentMethods.length > 0 const hasStudentCard = user.studentCard !== null return @@ -31,10 +26,11 @@ export default async function LedgerAccountPaymentMethods({ userId }: Props) { 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. +

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

+ Gå til siden for kortregistrering
} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx index c0e0dc321..21e4ede83 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountTransactionSummaryCard.tsx @@ -10,16 +10,6 @@ type Props = { export default function LedgerAccountTransactionSummary({ transactionsHref }: Props) { return - - - - - - - - - -
En transaksjon
En annen transaksjon
{ transactionsHref && diff --git a/src/app/_components/Ledger/Modals/BankCardModal.tsx b/src/app/_components/Ledger/Modals/BankCardModal.tsx deleted file mode 100644 index 4a711ae08..000000000 --- a/src/app/_components/Ledger/Modals/BankCardModal.tsx +++ /dev/null @@ -1,27 +0,0 @@ -'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/PaymentMethodList.module.scss b/src/app/_components/Ledger/Modals/PaymentMethodList.module.scss new file mode 100644 index 000000000..c75f1e2c7 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.module.scss @@ -0,0 +1,24 @@ +@use '@/styles/ohma'; + +.paymentMethodElement { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + + border-radius: ohma.$rounding; + border-width: 2px; + border-style: solid; + border-color: ohma.$colors-gray-600; + + margin: ohma.$gap 0; + padding: 2*ohma.$gap; + + font-size: ohma.$fonts-l; + align-items: center; +} + +.deletePaymentMethodButton { + font-size: ohma.$fonts-l; + color: ohma.$colors-gray-600; + justify-self: end; + @include ohma.roundBtn; +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx new file mode 100644 index 000000000..fcda76729 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx @@ -0,0 +1,47 @@ +'use client' + +import styles from './PaymentMethodList.module.scss' +import { deleteSavedPaymentMethodAction } from '@/services/stripeCustomers/actions' +import { FilteredPaymentMethod } from '@/services/stripeCustomers/types' +import { faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { useRouter } from 'next/navigation' + +type Params = { + paymentMethods: FilteredPaymentMethod[], +} + +export default function PaymentMethodList({ paymentMethods }: Params) { + const router = useRouter() + + const displayPaymentMethod = ({ type, card, id }: FilteredPaymentMethod) => { + switch (type) { + case 'card': + return <> + {card?.brand.toUpperCase()} + **** **** **** {card?.last4} + (utløper {card?.exp_month}/{card?.exp_year}) + + default: + return <>{type.toUpperCase()} + } + } + + const removePaymentMethod = async (id: string) => { + await deleteSavedPaymentMethodAction({ params: { paymentMethodId: id } }) + router.refresh() + } + + return ( +
    + {paymentMethods.map((method) => ( +
  • + {displayPaymentMethod(method)} + +
  • + ))} +
+ ) +} \ No newline at end of file diff --git a/src/app/_components/Ledger/Modals/BankCardModal.module.scss b/src/app/_components/Ledger/Modals/PaymentMethodModal.module.scss similarity index 100% rename from src/app/_components/Ledger/Modals/BankCardModal.module.scss rename to src/app/_components/Ledger/Modals/PaymentMethodModal.module.scss diff --git a/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx new file mode 100644 index 000000000..38a867129 --- /dev/null +++ b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx @@ -0,0 +1,56 @@ +'use client' + +import styles from './PaymentMethodModal.module.scss' +import PopUp from '@/app/_components/PopUp/PopUp' +import Button from '@/app/_components/UI/Button' +import Form from '@/components/Form/Form' +import StripePayment, { StripePaymentRef } from '@/components/Stripe/StripePayment' +import StripeProvider from '@/components/Stripe/StripeProvider' +import { createActionError } from '@/services/actionError' +import { createSetupIntentAction } from '@/services/stripeCustomers/actions' +import { useRef } from 'react' + +type PropTypes = { + userId: number, +} + +export default function PaymentMethodModal({ userId }: PropTypes) { + const stripePaymentRef = useRef(null) + + const handleSubmit = async () => { + if (!stripePaymentRef.current) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved innhenting av Stripe-komponenten.') + + const submitError = await stripePaymentRef.current.submit() + + if (submitError) return createActionError('BAD DATA', submitError) + + const createSetupIntentResult = await createSetupIntentAction({ params: { userId } }) + + if (!createSetupIntentResult.success) return createSetupIntentResult + + const setupError = await stripePaymentRef.current.confirmSetup(createSetupIntentResult.data.setupIntentClientSecret) + + if (setupError) return createActionError('UNKNOWN ERROR', setupError) + + return { + success: true, + data: undefined, + } as const + } + + return ( + } + > +

Legg til bankkort

+
+ + + + + +
+
+ ) +} diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index e5c08c635..eaadd7553 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -7,7 +7,7 @@ export default async function Transactions() { // shouldRedirect: true, // }) - const account = { id: 1 } + const account = { id: 2 } return } diff --git a/src/services/ledger/ledgerTransactions/operations.ts b/src/services/ledger/ledgerTransactions/operations.ts index e8a708db9..4a6c4897a 100644 --- a/src/services/ledger/ledgerTransactions/operations.ts +++ b/src/services/ledger/ledgerTransactions/operations.ts @@ -54,29 +54,31 @@ export const ledgerTransactionOperations = { accountId: z.number(), }), ), - operation: async ({ prisma, params }) => prisma.ledgerTransaction.findMany({ - where: { - ledgerEntries: { - some: { - ledgerAccountId: params.paging.details.accountId, + operation: async ({ prisma, params }) => { + return await prisma.ledgerTransaction.findMany({ + where: { + ledgerEntries: { + some: { + ledgerAccountId: params.paging.details.accountId, + }, }, }, - }, - include: { - ledgerEntries: true, - payment: { - include: { - stripePayment: true, - manualPayment: true, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, + }, }, }, - }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' }, - ], - ...cursorPageingSelection(params.paging.page) - }) + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(params.paging.page) + }) + } }), /** diff --git a/src/services/stripeCustomers/actions.ts b/src/services/stripeCustomers/actions.ts index f77b2621e..db3a90bfa 100644 --- a/src/services/stripeCustomers/actions.ts +++ b/src/services/stripeCustomers/actions.ts @@ -4,3 +4,6 @@ import { stripeCustomerOperations } from './operations' import { makeAction } from '@/services/serverAction' export const createStripeCustomerSessionAction = makeAction(stripeCustomerOperations.createSession) +export const createSetupIntentAction = makeAction(stripeCustomerOperations.createSetupIntent) +export const readSavedPaymentMethodsAction = makeAction(stripeCustomerOperations.readSavedPaymentMethods) +export const deleteSavedPaymentMethodAction = makeAction(stripeCustomerOperations.deleteSavedPaymentMethod) diff --git a/src/services/stripeCustomers/operations.ts b/src/services/stripeCustomers/operations.ts index 774668c46..898ddf445 100644 --- a/src/services/stripeCustomers/operations.ts +++ b/src/services/stripeCustomers/operations.ts @@ -3,6 +3,7 @@ import { defineOperation } from '@/services/serviceOperation' import { stripe } from '@/lib/stripe' import { RequireUserId } from '@/auth/auther/RequireUserId' import { z } from 'zod' +import { RequireNothing } from '@/auth/auther/RequireNothing' export const stripeCustomerOperations = { /** @@ -51,11 +52,20 @@ export const stripeCustomerOperations = { }, }) - return await prisma.stripeCustomer.create({ - data: { + // We use upsert here since two simultaneous requests could try to + // create the customer record in our database at the same time. + // (This has actually happened during testing.) + return await prisma.stripeCustomer.upsert({ + where: { + userId, + }, + create: { userId, customerId: customer.id, }, + update: { + // Dont update anything. Let the first created account win. + }, select: { customerId: true, }, @@ -93,7 +103,7 @@ export const stripeCustomerOperations = { }), /** - * Creates a Strip customer session which allows the frontend to manage the saved payment methods + * Creates a Stripe 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. @@ -142,4 +152,79 @@ export const stripeCustomerOperations = { } } }), + + /** + * Creates a setup intent for adding a new payment method to the user's customer account in Stripe. + */ + createSetupIntent: defineOperation({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId } }) => { + const customerId: string = (await stripeCustomerOperations.readOrCreate({ params: { userId } })).customerId + + const setupIntent = await stripe.setupIntents.create({ customer: customerId }) + + if (!setupIntent.client_secret) { + throw new ServerError( + 'UNKNOWN ERROR', + 'Noe gikk galt ved opprettelse av betalingsmetode.', + ) + } + + return { + setupIntentClientSecret: setupIntent.client_secret, + } + } + }), + + /** + * Returns a filtered list of saved payment methods for the user. + */ + readSavedPaymentMethods: defineOperation({ + authorizer: ({ params: { userId } }) => RequireUserId.staticFields({}).dynamicFields({ userId }), + paramsSchema: z.object({ + userId: z.number(), + }), + operation: async ({ params: { userId } }) => { + const customerId: string = (await stripeCustomerOperations.readOrCreate({ params: { userId } })).customerId + + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + }) + + // Filter out only the necessary information to return to the frontend. + // This is to avoid leaking any sensitive information. + const filteredPaymentMethods = paymentMethods.data.map(paymentMethod => ({ + id: paymentMethod.id, + type: paymentMethod.type, + card: paymentMethod.card && { + brand: paymentMethod.card.brand, + last4: paymentMethod.card.last4, + exp_month: paymentMethod.card.exp_month, + exp_year: paymentMethod.card.exp_year, + }, + })) + + return filteredPaymentMethods + } + }), + + /** + * Deletes (or "detaches" in Stripe lingo) a saved payment method from the user's customer account in Stripe. + */ + deleteSavedPaymentMethod: defineOperation({ + authorizer: () => RequireNothing.staticFields({}).dynamicFields({}), // TODO: This should probably be authed? + paramsSchema: z.object({ + paymentMethodId: z.string(), + }), + operation: async ({ params: { paymentMethodId } }) => { + await stripe.paymentMethods.detach(paymentMethodId) + + return { + success: true + } + } + }), } diff --git a/src/services/stripeCustomers/types.ts b/src/services/stripeCustomers/types.ts new file mode 100644 index 000000000..c28d9ab9f --- /dev/null +++ b/src/services/stripeCustomers/types.ts @@ -0,0 +1,7 @@ +import Stripe from "stripe" + +export type FilteredPaymentMethod = { + id: string, + type: Stripe.PaymentMethod.Type, + card?: Pick, +} \ No newline at end of file From a89e4a7bb5f2dbfb18226b9bc9d8a6757b168324 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 12 Jan 2026 15:49:47 +0100 Subject: [PATCH 47/62] fix: group account page loading --- src/app/admin/accounts/[accountId]/page.tsx | 6 ++++- .../[username]/(user-admin)/account/page.tsx | 2 +- .../ledger/ledgerAccount/operations.ts | 24 +++++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx index 844621de6..ac9d0a65b 100644 --- a/src/app/admin/accounts/[accountId]/page.tsx +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -1,5 +1,7 @@ +import { unwrapActionReturn } from '@/app/redirectToErrorPage' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' +import { readLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' import { notFound } from 'next/navigation' type Props = { @@ -15,8 +17,10 @@ export default async function LedgerAccount({ params }: Props) { notFound() } + const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { ledgerAccountId: accountId } })) + return
- + {/* Add link to products overview */}
diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index de46d3d49..e4e582aad 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -14,7 +14,7 @@ export default async function Account() { const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { userId: session.user.id } })) return
- +
diff --git a/src/services/ledger/ledgerAccount/operations.ts b/src/services/ledger/ledgerAccount/operations.ts index 9dda0cc29..e86d07f4c 100644 --- a/src/services/ledger/ledgerAccount/operations.ts +++ b/src/services/ledger/ledgerAccount/operations.ts @@ -47,14 +47,15 @@ export const ledgerAccountOperations = { }), /** - * Reads details of a ledger account for a given user or group. - * The account will be created if it does not exist. + * Reads details of a ledger account by ledger account id, user id, or group id. + * If searching by userId/groupId 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. + * @param params.ledgerAccountId The ID of the ledger account to read. * * @returns The account details. */ @@ -64,13 +65,32 @@ export const ledgerAccountOperations = { z.object({ userId: z.number(), groupId: z.undefined(), + ledgerAccountId: z.undefined(), }), z.object({ groupId: z.number(), userId: z.undefined(), + ledgerAccountId: z.undefined(), + }), + z.object({ + groupId: z.undefined(), + userId: z.undefined(), + ledgerAccountId: z.number(), }), ]), operation: async ({ prisma, session, params }): Promise => { + // If searching by ledger account id we don't want to create a new account if it doesn't exist. + if (params.ledgerAccountId !== undefined) { + return await prisma.ledgerAccount.findUniqueOrThrow({ + where: { + id: params.ledgerAccountId, + }, + }) + } + + // If searching by userId/groupId we want to create the account if it doesn't exist. + // TODO: Is this something we want? + const account = await prisma.ledgerAccount.findUnique({ where: { userId: params.userId, From acdc7330e9c124ede46916c74f9c4f65cfa31b50 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 12 Jan 2026 16:52:34 +0100 Subject: [PATCH 48/62] refactor: rename ledger operation operations to ledger movement operations --- .../_components/Ledger/Accounts/LedgerAccountBalance.tsx | 2 +- .../Ledger/Accounts/LedgerAccountFreezeButton.tsx | 2 +- .../Ledger/Accounts/LedgerAccountOverviewCard.tsx | 2 +- src/app/_components/Ledger/Modals/CheckoutModal.tsx | 2 +- .../Ledger/Transactions/LedgerTransactionRow.tsx | 2 +- src/app/admin/accounts/[accountId]/page.tsx | 2 +- src/app/users/[username]/(user-admin)/account/page.tsx | 2 +- src/contexts/paging/LedgerAccountPaging.tsx | 2 +- src/contexts/paging/LedgerTransactionPaging.tsx | 4 ++-- .../ledger/{ledgerAccount => accounts}/actions.ts | 0 src/services/ledger/{ledgerAccount => accounts}/auth.ts | 0 .../ledger/{ledgerAccount => accounts}/operations.ts | 0 .../ledger/{ledgerAccount => accounts}/schemas.ts | 0 src/services/ledger/{ledgerAccount => accounts}/types.ts | 0 src/services/ledger/ledgerOperations/actions.ts | 7 ------- src/services/ledger/movements/actions.ts | 7 +++++++ .../ledger/{ledgerOperations => movements}/operations.ts | 8 ++++---- .../ledger/{ledgerOperations => movements}/schemas.ts | 2 +- .../{ledgerTransactions => transactions}/actions.ts | 0 .../{ledgerTransactions => transactions}/calculateFees.ts | 2 +- .../determineTransactionState.ts | 2 +- .../{ledgerTransactions => transactions}/operations.ts | 2 +- .../ledger/{ledgerTransactions => transactions}/types.ts | 0 tests/services/ledger/calculateFees.test.ts | 2 +- tests/services/ledger/ledgerTransactions.test.ts | 4 ++-- 25 files changed, 28 insertions(+), 28 deletions(-) rename src/services/ledger/{ledgerAccount => accounts}/actions.ts (100%) rename src/services/ledger/{ledgerAccount => accounts}/auth.ts (100%) rename src/services/ledger/{ledgerAccount => accounts}/operations.ts (100%) rename src/services/ledger/{ledgerAccount => accounts}/schemas.ts (100%) rename src/services/ledger/{ledgerAccount => accounts}/types.ts (100%) delete mode 100644 src/services/ledger/ledgerOperations/actions.ts create mode 100644 src/services/ledger/movements/actions.ts rename src/services/ledger/{ledgerOperations => movements}/operations.ts (94%) rename src/services/ledger/{ledgerOperations => movements}/schemas.ts (89%) rename src/services/ledger/{ledgerTransactions => transactions}/actions.ts (100%) rename src/services/ledger/{ledgerTransactions => transactions}/calculateFees.ts (97%) rename src/services/ledger/{ledgerTransactions => transactions}/determineTransactionState.ts (98%) rename src/services/ledger/{ledgerTransactions => transactions}/operations.ts (99%) rename src/services/ledger/{ledgerTransactions => transactions}/types.ts (100%) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx index 9129dd394..42caf3c73 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountBalance.tsx @@ -1,7 +1,7 @@ import styles from './LedgerAccountBalance.module.scss' import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { displayAmount } from '@/lib/currency/convert' -import { calculateLedgerAccountBalanceAction } from '@/services/ledger/ledgerAccount/actions' +import { calculateLedgerAccountBalanceAction } from '@/services/ledger/accounts/actions' type Props = { ledgerAccountId: number, diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx index f58838444..f60c7b796 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx @@ -1,7 +1,7 @@ 'use client' import Button from '@/components/UI/Button' -import { updateLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import { updateLedgerAccountAction } from '@/services/ledger/accounts/actions' import { LedgerAccount } from '@prisma/client' import { useRouter } from 'next/navigation'; diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 0f72c5471..6bd369af7 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -8,7 +8,7 @@ import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/ac import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faWarning } from '@fortawesome/free-solid-svg-icons' import { LedgerAccount } from '@prisma/client' -import { updateLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import { updateLedgerAccountAction } from '@/services/ledger/accounts/actions' import LedgerAccountFreezeButton from './LedgerAccountFreezeButton' type Props = { diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.tsx b/src/app/_components/Ledger/Modals/CheckoutModal.tsx index 7dbb5ab34..426d03018 100644 --- a/src/app/_components/Ledger/Modals/CheckoutModal.tsx +++ b/src/app/_components/Ledger/Modals/CheckoutModal.tsx @@ -5,7 +5,7 @@ import PopUp from '@/components/PopUp/PopUp' import Button from '@/components/UI/Button' import { createActionError } from '@/services/actionError' import React, { useState, lazy, useRef } from 'react' -import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' +import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' import type { PaymentProvider } from '@prisma/client' import type { StripePaymentRef } from '@/components/Stripe/StripePayment' import type { ActionReturn } from '@/services/actionTypes' diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx index e77f87dec..fa089eb34 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -1,6 +1,6 @@ import styles from './LedgerTransactionRow.module.scss' import { displayAmount } from '@/lib/currency/convert' -import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' +import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' type Props = { transaction: ExpandedLedgerTransaction, diff --git a/src/app/admin/accounts/[accountId]/page.tsx b/src/app/admin/accounts/[accountId]/page.tsx index ac9d0a65b..679b42b33 100644 --- a/src/app/admin/accounts/[accountId]/page.tsx +++ b/src/app/admin/accounts/[accountId]/page.tsx @@ -1,7 +1,7 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' -import { readLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import { readLedgerAccountAction } from '@/services/ledger/accounts/actions' import { notFound } from 'next/navigation' type Props = { diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index e4e582aad..0f08bf2f1 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,6 +1,6 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/session/getUser' -import { calculateLedgerAccountBalanceAction, readLedgerAccountAction } from '@/services/ledger/ledgerAccount/actions' +import { calculateLedgerAccountBalanceAction, readLedgerAccountAction } from '@/services/ledger/accounts/actions' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountPaymentMethods from '@/components/Ledger/Accounts/LedgerAccountPaymentMethodsCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' diff --git a/src/contexts/paging/LedgerAccountPaging.tsx b/src/contexts/paging/LedgerAccountPaging.tsx index bf21d1a8f..225593f52 100644 --- a/src/contexts/paging/LedgerAccountPaging.tsx +++ b/src/contexts/paging/LedgerAccountPaging.tsx @@ -1,7 +1,7 @@ 'use client' import { generatePaging } from './PagingGenerator' -import { readLedgerAccountPageAction } from '@/services/ledger/ledgerAccount/actions' +import { readLedgerAccountPageAction } from '@/services/ledger/accounts/actions' import type { LedgerAccount, LedgerAccountType } from '@prisma/client' export type PageSizeTransactions = 10 diff --git a/src/contexts/paging/LedgerTransactionPaging.tsx b/src/contexts/paging/LedgerTransactionPaging.tsx index d36b9b4e2..0f4f747b9 100644 --- a/src/contexts/paging/LedgerTransactionPaging.tsx +++ b/src/contexts/paging/LedgerTransactionPaging.tsx @@ -1,8 +1,8 @@ 'use client' import { generatePaging } from './PagingGenerator' -import { readLedgerTransactionPageAction } from '@/services/ledger/ledgerTransactions/actions' -import type { ExpandedLedgerTransaction } from '@/services/ledger/ledgerTransactions/types' +import { readLedgerTransactionPageAction } from '@/services/ledger/transactions/actions' +import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' // TODO: Might be possible to cleanup? Why is size a type??? export type PageSizeTransactions = 10 diff --git a/src/services/ledger/ledgerAccount/actions.ts b/src/services/ledger/accounts/actions.ts similarity index 100% rename from src/services/ledger/ledgerAccount/actions.ts rename to src/services/ledger/accounts/actions.ts diff --git a/src/services/ledger/ledgerAccount/auth.ts b/src/services/ledger/accounts/auth.ts similarity index 100% rename from src/services/ledger/ledgerAccount/auth.ts rename to src/services/ledger/accounts/auth.ts diff --git a/src/services/ledger/ledgerAccount/operations.ts b/src/services/ledger/accounts/operations.ts similarity index 100% rename from src/services/ledger/ledgerAccount/operations.ts rename to src/services/ledger/accounts/operations.ts diff --git a/src/services/ledger/ledgerAccount/schemas.ts b/src/services/ledger/accounts/schemas.ts similarity index 100% rename from src/services/ledger/ledgerAccount/schemas.ts rename to src/services/ledger/accounts/schemas.ts diff --git a/src/services/ledger/ledgerAccount/types.ts b/src/services/ledger/accounts/types.ts similarity index 100% rename from src/services/ledger/ledgerAccount/types.ts rename to src/services/ledger/accounts/types.ts diff --git a/src/services/ledger/ledgerOperations/actions.ts b/src/services/ledger/ledgerOperations/actions.ts deleted file mode 100644 index 1f26917f9..000000000 --- a/src/services/ledger/ledgerOperations/actions.ts +++ /dev/null @@ -1,7 +0,0 @@ -'use server' - -import { ledgerOperationOperations } from './operations' -import { makeAction } from '@/services/serverAction' - -export const createDepositAction = makeAction(ledgerOperationOperations.createDeposit) -export const createPayout = makeAction(ledgerOperationOperations.createPayout) diff --git a/src/services/ledger/movements/actions.ts b/src/services/ledger/movements/actions.ts new file mode 100644 index 000000000..a6cb59ad8 --- /dev/null +++ b/src/services/ledger/movements/actions.ts @@ -0,0 +1,7 @@ +'use server' + +import { ledgerMovementOperations } from './operations' +import { makeAction } from '@/services/serverAction' + +export const createDepositAction = makeAction(ledgerMovementOperations.createDeposit) +export const createPayoutAction = makeAction(ledgerMovementOperations.createPayout) diff --git a/src/services/ledger/ledgerOperations/operations.ts b/src/services/ledger/movements/operations.ts similarity index 94% rename from src/services/ledger/ledgerOperations/operations.ts rename to src/services/ledger/movements/operations.ts index 098dfbe76..024fd2ce0 100644 --- a/src/services/ledger/ledgerOperations/operations.ts +++ b/src/services/ledger/movements/operations.ts @@ -1,16 +1,16 @@ -import { ledgerTransactionOperations } from '@/services/ledger/ledgerTransactions/operations' +import { ledgerTransactionOperations } from '@/services/ledger/transactions/operations' import { paymentOperations } from '@/services/ledger/payments/operations' import { RequireNothing } from '@/auth/auther/RequireNothing' import { defineOperation } from '@/services/serviceOperation' import { z } from 'zod' import { PaymentProvider } from '@prisma/client' -// `LedgerOperations` provides functions to orchestrate account related actions, +// `ledgerMovementOperations` 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`. +// `ledgerTransactionOperations`. -export const ledgerOperationOperations = { +export const ledgerMovementOperations = { /** * Creates a deposit transaction, which is a deposit of funds into the ledger. * diff --git a/src/services/ledger/ledgerOperations/schemas.ts b/src/services/ledger/movements/schemas.ts similarity index 89% rename from src/services/ledger/ledgerOperations/schemas.ts rename to src/services/ledger/movements/schemas.ts index 44e4d41f6..965965ffc 100644 --- a/src/services/ledger/ledgerOperations/schemas.ts +++ b/src/services/ledger/movements/schemas.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -export const ledgerOperationSchemas = { +export const ledgerMovementSchemas = { createDeposit: z.object({ }), diff --git a/src/services/ledger/ledgerTransactions/actions.ts b/src/services/ledger/transactions/actions.ts similarity index 100% rename from src/services/ledger/ledgerTransactions/actions.ts rename to src/services/ledger/transactions/actions.ts diff --git a/src/services/ledger/ledgerTransactions/calculateFees.ts b/src/services/ledger/transactions/calculateFees.ts similarity index 97% rename from src/services/ledger/ledgerTransactions/calculateFees.ts rename to src/services/ledger/transactions/calculateFees.ts index a230b7330..3a2539aab 100644 --- a/src/services/ledger/ledgerTransactions/calculateFees.ts +++ b/src/services/ledger/transactions/calculateFees.ts @@ -1,4 +1,4 @@ -import type { BalanceRecord } from '@/services/ledger/ledgerAccount/types' +import type { BalanceRecord } from '@/services/ledger/accounts/types' /** * Calculates fees proportional to the ratio between `entryAmount` and `totalAmount`. diff --git a/src/services/ledger/ledgerTransactions/determineTransactionState.ts b/src/services/ledger/transactions/determineTransactionState.ts similarity index 98% rename from src/services/ledger/ledgerTransactions/determineTransactionState.ts rename to src/services/ledger/transactions/determineTransactionState.ts index e8b8eef38..213f5f81e 100644 --- a/src/services/ledger/ledgerTransactions/determineTransactionState.ts +++ b/src/services/ledger/transactions/determineTransactionState.ts @@ -1,5 +1,5 @@ import type { ExpandedLedgerTransaction } from './types' -import type { BalanceRecord } from '@/services/ledger/ledgerAccount/types' +import type { BalanceRecord } from '@/services/ledger/accounts/types' import type { LedgerAccount, LedgerTransactionState, PaymentState } from '@prisma/client' type LedgerTransactionTransition = { diff --git a/src/services/ledger/ledgerTransactions/operations.ts b/src/services/ledger/transactions/operations.ts similarity index 99% rename from src/services/ledger/ledgerTransactions/operations.ts rename to src/services/ledger/transactions/operations.ts index 4a6c4897a..4bbc6f4cf 100644 --- a/src/services/ledger/ledgerTransactions/operations.ts +++ b/src/services/ledger/transactions/operations.ts @@ -1,6 +1,6 @@ import { calculateCreditFees, calculateDebitFees } from './calculateFees' import { determineTransactionState } from './determineTransactionState' -import { ledgerAccountOperations } from '@/services/ledger/ledgerAccount/operations' +import { ledgerAccountOperations } from '@/services/ledger/accounts/operations' import { RequireNothing } from '@/auth/auther/RequireNothing' import { cursorPageingSelection } from '@/lib/paging/cursorPageingSelection' import { readPageInputSchemaObject } from '@/lib/paging/schema' diff --git a/src/services/ledger/ledgerTransactions/types.ts b/src/services/ledger/transactions/types.ts similarity index 100% rename from src/services/ledger/ledgerTransactions/types.ts rename to src/services/ledger/transactions/types.ts diff --git a/tests/services/ledger/calculateFees.test.ts b/tests/services/ledger/calculateFees.test.ts index 9f45d4527..09784945a 100644 --- a/tests/services/ledger/calculateFees.test.ts +++ b/tests/services/ledger/calculateFees.test.ts @@ -1,4 +1,4 @@ -import { feesFormula } from '@/services/ledger/ledgerTransactions/calculateFees' +import { feesFormula } from '@/services/ledger/transactions/calculateFees' import { describe, expect, test } from '@jest/globals' type FeeInputOutput = [ diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index 813df27ca..47085a3a4 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -1,9 +1,9 @@ import { allSettledOrThrow } from 'tests/utils' import { prisma } from '@/prisma/client' -import { ledgerAccountOperations } from '@/services/ledger/ledgerAccount/operations' +import { ledgerAccountOperations } from '@/services/ledger/accounts/operations' import { userOperations } from '@/services/users/operations' import { paymentOperations } from '@/services/ledger/payments/operations' -import { ledgerTransactionOperations } from '@/services/ledger/ledgerTransactions/operations' +import { ledgerTransactionOperations } from '@/services/ledger/transactions/operations' import { beforeAll, beforeEach, afterEach, describe, expect, test } from '@jest/globals' const TEST_ACCOUNT_COUNT = 3 From 09193a1c5bf5da36a0d75d5c873faf8cb2794692 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 12 Jan 2026 16:52:40 +0100 Subject: [PATCH 49/62] style: linting --- .../Accounts/LedgerAccountFreezeButton.tsx | 17 ++++--- .../Accounts/LedgerAccountOverviewCard.tsx | 16 ++++--- .../LedgerAccountPaymentMethodsCard.tsx | 6 ++- .../Ledger/Modals/DepositModal.tsx | 2 +- .../Ledger/Modals/PaymentMethodList.tsx | 10 ++--- .../Ledger/Modals/PaymentMethodModal.tsx | 11 ++--- .../_components/Ledger/Modals/PayoutModal.tsx | 4 +- .../[username]/(user-admin)/account/page.tsx | 10 ++++- src/services/ledger/accounts/operations.ts | 18 ++++---- .../transactions/determineTransactionState.ts | 6 ++- .../ledger/transactions/operations.ts | 44 +++++++++---------- src/services/stripeCustomers/operations.ts | 6 +-- src/services/stripeCustomers/types.ts | 4 +- 13 files changed, 86 insertions(+), 68 deletions(-) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx index f60c7b796..d03b30620 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountFreezeButton.tsx @@ -2,11 +2,14 @@ import Button from '@/components/UI/Button' import { updateLedgerAccountAction } from '@/services/ledger/accounts/actions' -import { LedgerAccount } from '@prisma/client' -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation' +import type { LedgerAccount } from '@prisma/client' -export default function LedgerAccountFreezeButton({ ledgerAccount, className }: { ledgerAccount: LedgerAccount, className?: string }) { - const { refresh } = useRouter(); +export default function LedgerAccountFreezeButton({ + ledgerAccount, + className +}: { ledgerAccount: LedgerAccount, className?: string }) { + const { refresh } = useRouter() const toggleFrozen = async () => { await updateLedgerAccountAction({ @@ -22,6 +25,8 @@ export default function LedgerAccountFreezeButton({ ledgerAccount, className }: } return ( - + ) -} \ No newline at end of file +} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 6bd369af7..723af9d64 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -1,5 +1,6 @@ import styles from './LedgerAccountOverviewCard.module.scss' import LedgerAccountBalance from './LedgerAccountBalance' +import LedgerAccountFreezeButton from './LedgerAccountFreezeButton' import Card from '@/components/UI/Card' import DepositModal from '@/components/Ledger/Modals/DepositModal' import PayoutModal from '@/components/Ledger/Modals/PayoutModal' @@ -7,9 +8,7 @@ import { getUser } from '@/auth/session/getUser' import { createStripeCustomerSessionAction } from '@/services/stripeCustomers/actions' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faWarning } from '@fortawesome/free-solid-svg-icons' -import { LedgerAccount } from '@prisma/client' -import { updateLedgerAccountAction } from '@/services/ledger/accounts/actions' -import LedgerAccountFreezeButton from './LedgerAccountFreezeButton' +import type { LedgerAccount } from '@prisma/client' type Props = { ledgerAccount: LedgerAccount, @@ -48,7 +47,11 @@ export default async function LedgerAccountOverview({
{ -

Kontoen er fryst; Ingen transaksjoner kan utføres.

+

+ Kontoen er fryst; Ingen transaksjoner kan utføres. +

}
@@ -57,7 +60,10 @@ export default async function LedgerAccountOverview({ } { showPayoutButton && } - { showDeactivateButton && } + { + showDeactivateButton && + + }
} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index 430dafa12..eb5596011 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -1,3 +1,4 @@ +import PaymentMethodList from '@/components/Ledger/Modals/PaymentMethodList' import PaymentMethodModal from '@/components/Ledger/Modals/PaymentMethodModal' import Card from '@/components/UI/Card' import { unwrapActionReturn } from '@/app/redirectToErrorPage' @@ -5,7 +6,6 @@ import { readUserAction } from '@/services/users/actions' import BooleanIndicator from '@/components/UI/BooleanIndicator' import { readSavedPaymentMethodsAction } from '@/services/stripeCustomers/actions' import Link from 'next/link' -import PaymentMethodList from '../Modals/PaymentMethodList' import { faArrowRight } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -31,6 +31,8 @@ export default async function LedgerAccountPaymentMethods({ userId }: Props) {

NTNU-kort

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

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

- Gå til siden for kortregistrering + + Gå til siden for kortregistrering + } diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 7cc2871d9..587369bc3 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -5,12 +5,12 @@ 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/constants' import Checkbox from '@/components/UI/Checkbox' import TextInput from '@/components/UI/TextInput' +import { createDepositAction } from '@/services/ledger/movements/actions' import { lazy, useRef, useState } from 'react' import type { PaymentProvider } from '@prisma/client' import type { ExpandedPayment } from '@/services/ledger/payments/types' diff --git a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx index fcda76729..7040e94fa 100644 --- a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx @@ -2,10 +2,10 @@ import styles from './PaymentMethodList.module.scss' import { deleteSavedPaymentMethodAction } from '@/services/stripeCustomers/actions' -import { FilteredPaymentMethod } from '@/services/stripeCustomers/types' import { faXmark } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { useRouter } from 'next/navigation' +import type { FilteredPaymentMethod } from '@/services/stripeCustomers/types' type Params = { paymentMethods: FilteredPaymentMethod[], @@ -14,7 +14,7 @@ type Params = { export default function PaymentMethodList({ paymentMethods }: Params) { const router = useRouter() - const displayPaymentMethod = ({ type, card, id }: FilteredPaymentMethod) => { + const displayPaymentMethod = ({ type, card }: FilteredPaymentMethod) => { switch (type) { case 'card': return <> @@ -31,12 +31,12 @@ export default function PaymentMethodList({ paymentMethods }: Params) { await deleteSavedPaymentMethodAction({ params: { paymentMethodId: id } }) router.refresh() } - + return (
    {paymentMethods.map((method) => (
  • - {displayPaymentMethod(method)} + {displayPaymentMethod(method)} @@ -44,4 +44,4 @@ export default function PaymentMethodList({ paymentMethods }: Params) { ))}
) -} \ No newline at end of file +} diff --git a/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx index 38a867129..e20e23436 100644 --- a/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx +++ b/src/app/_components/Ledger/Modals/PaymentMethodModal.tsx @@ -4,11 +4,12 @@ import styles from './PaymentMethodModal.module.scss' import PopUp from '@/app/_components/PopUp/PopUp' import Button from '@/app/_components/UI/Button' import Form from '@/components/Form/Form' -import StripePayment, { StripePaymentRef } from '@/components/Stripe/StripePayment' +import StripePayment from '@/components/Stripe/StripePayment' import StripeProvider from '@/components/Stripe/StripeProvider' import { createActionError } from '@/services/actionError' import { createSetupIntentAction } from '@/services/stripeCustomers/actions' import { useRef } from 'react' +import type { StripePaymentRef } from '@/components/Stripe/StripePayment' type PropTypes = { userId: number, @@ -18,8 +19,8 @@ export default function PaymentMethodModal({ userId }: PropTypes) { const stripePaymentRef = useRef(null) const handleSubmit = async () => { - if (!stripePaymentRef.current) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved innhenting av Stripe-komponenten.') - + if (!stripePaymentRef.current) return createActionError('UNKNOWN ERROR', 'Noe gikk galt ved innhenting av Stripe.') + const submitError = await stripePaymentRef.current.submit() if (submitError) return createActionError('BAD DATA', submitError) @@ -37,7 +38,7 @@ export default function PaymentMethodModal({ userId }: PropTypes) { data: undefined, } as const } - + return (

Legg til bankkort

-
+ diff --git a/src/app/_components/Ledger/Modals/PayoutModal.tsx b/src/app/_components/Ledger/Modals/PayoutModal.tsx index 3a74ee9c8..0e0c36c07 100644 --- a/src/app/_components/Ledger/Modals/PayoutModal.tsx +++ b/src/app/_components/Ledger/Modals/PayoutModal.tsx @@ -5,9 +5,9 @@ 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 { createPayout } from '@/services/ledger/ledgerOperations/actions' import { convertAmount } from '@/lib/currency/convert' import { configureAction } from '@/services/configureAction' +import { createPayoutAction } from '@/services/ledger/movements/actions' import { useState } from 'react' type Props = { @@ -27,7 +27,7 @@ export default function PayoutModal({ ledgerAccountId, defaultFunds = 0, default

Ny utbetaling

diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 0f08bf2f1..3af950b19 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,6 +1,6 @@ import { unwrapActionReturn } from '@/app/redirectToErrorPage' import { getUser } from '@/auth/session/getUser' -import { calculateLedgerAccountBalanceAction, readLedgerAccountAction } from '@/services/ledger/accounts/actions' +import { readLedgerAccountAction } from '@/services/ledger/accounts/actions' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountPaymentMethods from '@/components/Ledger/Accounts/LedgerAccountPaymentMethodsCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' @@ -14,7 +14,13 @@ export default async function Account() { const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { userId: session.user.id } })) return
- +
diff --git a/src/services/ledger/accounts/operations.ts b/src/services/ledger/accounts/operations.ts index e86d07f4c..928a98cba 100644 --- a/src/services/ledger/accounts/operations.ts +++ b/src/services/ledger/accounts/operations.ts @@ -132,10 +132,10 @@ export const ledgerAccountOperations = { /** * Updates a ledger account with the given data. - * + * * @param params.id The ID of the account to update. * @param data The data to update the account with. - * + * * @returns The updated account. */ update: defineOperation({ @@ -144,14 +144,12 @@ export const ledgerAccountOperations = { id: z.number(), }), dataSchema: ledgerAccountSchemas.update, - operation: async ({ prisma, params, data }) => { - return prisma.ledgerAccount.update({ - where: { - id: params.id, - }, - data, - }) - } + operation: async ({ prisma, params, data }) => prisma.ledgerAccount.update({ + where: { + id: params.id, + }, + data, + }) }), /** diff --git a/src/services/ledger/transactions/determineTransactionState.ts b/src/services/ledger/transactions/determineTransactionState.ts index 213f5f81e..60e6f0696 100644 --- a/src/services/ledger/transactions/determineTransactionState.ts +++ b/src/services/ledger/transactions/determineTransactionState.ts @@ -1,6 +1,6 @@ import type { ExpandedLedgerTransaction } from './types' import type { BalanceRecord } from '@/services/ledger/accounts/types' -import type { LedgerAccount, LedgerTransactionState, PaymentState } from '@prisma/client' +import type { LedgerTransactionState, PaymentState } from '@prisma/client' type LedgerTransactionTransition = { state: LedgerTransactionState, @@ -125,7 +125,9 @@ function validAmountSum( function sufficientBalances( { transaction, balances }: LedgerTransactionRuleContext, ): LedgerTransactionTransition | null { - const debitLedgerAccountIds = transaction.ledgerEntries.filter(entry => entry.funds < 0).map(entry => entry.ledgerAccountId) + const debitLedgerAccountIds = transaction.ledgerEntries + .filter(entry => entry.funds < 0) + .map(entry => entry.ledgerAccountId) const debitBalances = debitLedgerAccountIds.map(id => balances[id]) if (debitBalances.some(balance => !balance)) { diff --git a/src/services/ledger/transactions/operations.ts b/src/services/ledger/transactions/operations.ts index 4bbc6f4cf..9d113d3c4 100644 --- a/src/services/ledger/transactions/operations.ts +++ b/src/services/ledger/transactions/operations.ts @@ -54,31 +54,29 @@ export const ledgerTransactionOperations = { accountId: z.number(), }), ), - operation: async ({ prisma, params }) => { - return await prisma.ledgerTransaction.findMany({ - where: { - ledgerEntries: { - some: { - ledgerAccountId: params.paging.details.accountId, - }, + operation: async ({ prisma, params }) => await prisma.ledgerTransaction.findMany({ + where: { + ledgerEntries: { + some: { + ledgerAccountId: params.paging.details.accountId, }, }, - include: { - ledgerEntries: true, - payment: { - include: { - stripePayment: true, - manualPayment: true, - }, + }, + include: { + ledgerEntries: true, + payment: { + include: { + stripePayment: true, + manualPayment: true, }, }, - orderBy: [ - { createdAt: 'desc' }, - { id: 'desc' }, - ], - ...cursorPageingSelection(params.paging.page) - }) - } + }, + orderBy: [ + { createdAt: 'desc' }, + { id: 'desc' }, + ], + ...cursorPageingSelection(params.paging.page) + }) }), /** @@ -98,7 +96,7 @@ export const ledgerTransactionOperations = { const creditFees = calculateCreditFees(transaction.ledgerEntries, transaction.payment) // Update credit fees if they could be calculated. - // Credit fees are null while the payment is pending, since + // Credit fees are null while the payment is pending, since // the final fees are unknown until the payment is completed. if (creditFees) { const creditEntries = transaction.ledgerEntries.filter(entry => entry.funds > 0) @@ -124,7 +122,7 @@ export const ledgerTransactionOperations = { }, }, }) - + transaction.ledgerEntries.forEach(entry => { entry.fees = creditFees[entry.ledgerAccountId] ?? entry.fees }) diff --git a/src/services/stripeCustomers/operations.ts b/src/services/stripeCustomers/operations.ts index 898ddf445..614fb2f8c 100644 --- a/src/services/stripeCustomers/operations.ts +++ b/src/services/stripeCustomers/operations.ts @@ -2,8 +2,8 @@ import { ServerError } from '@/services/error' import { defineOperation } from '@/services/serviceOperation' import { stripe } from '@/lib/stripe' import { RequireUserId } from '@/auth/auther/RequireUserId' -import { z } from 'zod' import { RequireNothing } from '@/auth/auther/RequireNothing' +import { z } from 'zod' export const stripeCustomerOperations = { /** @@ -52,7 +52,7 @@ export const stripeCustomerOperations = { }, }) - // We use upsert here since two simultaneous requests could try to + // We use upsert here since two simultaneous requests could try to // create the customer record in our database at the same time. // (This has actually happened during testing.) return await prisma.stripeCustomer.upsert({ @@ -221,7 +221,7 @@ export const stripeCustomerOperations = { }), operation: async ({ params: { paymentMethodId } }) => { await stripe.paymentMethods.detach(paymentMethodId) - + return { success: true } diff --git a/src/services/stripeCustomers/types.ts b/src/services/stripeCustomers/types.ts index c28d9ab9f..8bb714725 100644 --- a/src/services/stripeCustomers/types.ts +++ b/src/services/stripeCustomers/types.ts @@ -1,7 +1,7 @@ -import Stripe from "stripe" +import type Stripe from 'stripe' export type FilteredPaymentMethod = { id: string, type: Stripe.PaymentMethod.Type, card?: Pick, -} \ No newline at end of file +} From 3715d8abda8d432ef7261f4a49ccf369ad5ef638 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 12 Jan 2026 18:17:26 +0100 Subject: [PATCH 50/62] fix: account page not loading with missing keys --- .../Ledger/Accounts/LedgerAccountOverviewCard.tsx | 4 +--- .../Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx | 5 ++++- src/app/_components/Ledger/Modals/PaymentMethodList.tsx | 4 ++-- src/services/ledger/movements/operations.ts | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx index 723af9d64..f0324309f 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountOverviewCard.tsx @@ -47,9 +47,7 @@ export default async function LedgerAccountOverview({
{ -

+

Kontoen er fryst; Ingen transaksjoner kan utføres.

} diff --git a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx index eb5596011..15f61d9d2 100644 --- a/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx +++ b/src/app/_components/Ledger/Accounts/LedgerAccountPaymentMethodsCard.tsx @@ -15,7 +15,10 @@ type Props = { export default async function LedgerAccountPaymentMethods({ userId }: Props) { const user = unwrapActionReturn(await readUserAction({ params: { id: userId } })) // TODO: Change to better method - const savedPaymentMethods = unwrapActionReturn(await readSavedPaymentMethodsAction({ params: { userId } })) + const savedPaymentMethodsResult = await readSavedPaymentMethodsAction({ params: { userId }}) + const savedPaymentMethods = savedPaymentMethodsResult.success + ? savedPaymentMethodsResult.data + : [] const hasBankCard = savedPaymentMethods.length > 0 const hasStudentCard = user.studentCard !== null diff --git a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx index 7040e94fa..0385960ef 100644 --- a/src/app/_components/Ledger/Modals/PaymentMethodList.tsx +++ b/src/app/_components/Ledger/Modals/PaymentMethodList.tsx @@ -34,14 +34,14 @@ export default function PaymentMethodList({ paymentMethods }: Params) { return (
    - {paymentMethods.map((method) => ( + {paymentMethods.length > 0 ? paymentMethods.map((method) => (
  • {displayPaymentMethod(method)}
  • - ))} + )) :

    Du har ingen lagrede betalingskort.

    }
) } diff --git a/src/services/ledger/movements/operations.ts b/src/services/ledger/movements/operations.ts index 024fd2ce0..996378df3 100644 --- a/src/services/ledger/movements/operations.ts +++ b/src/services/ledger/movements/operations.ts @@ -33,7 +33,7 @@ export const ledgerMovementOperations = { params: { provider: params.provider, funds: params.funds, - descriptionLong: 'Innskudd', + descriptionLong: 'Innskudd til veven', descriptionShort: 'Innskudd', }, prisma: tx, @@ -83,7 +83,7 @@ export const ledgerMovementOperations = { const payment = await paymentOperations.create({ params: { provider: 'MANUAL', - descriptionLong: 'Utbetaling', + descriptionLong: 'Utbetaling fra veven', descriptionShort: 'Utbetaling', funds: -params.funds, }, From 0722b22627bb43993652305fdf70b913cbc195eb Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Tue, 13 Jan 2026 08:28:38 +0100 Subject: [PATCH 51/62] fix: broken test --- src/prisma/schema/ledger.prisma | 8 +-- src/services/ledger/payments/operations.ts | 61 ++++++++----------- src/services/users/operations.ts | 5 +- .../ledger/ledgerTransactions.test.ts | 4 +- tests/services/ledger/payouts.test.ts | 7 --- tests/services/ledger/transactions.test.ts | 7 --- 6 files changed, 36 insertions(+), 56 deletions(-) delete mode 100644 tests/services/ledger/payouts.test.ts delete mode 100644 tests/services/ledger/transactions.test.ts diff --git a/src/prisma/schema/ledger.prisma b/src/prisma/schema/ledger.prisma index 390c927a8..25e9b45e2 100644 --- a/src/prisma/schema/ledger.prisma +++ b/src/prisma/schema/ledger.prisma @@ -1,6 +1,6 @@ // Join table between groups and their ledger accounts model GroupLedgerAccount { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // TODO: Finnish this // group Group @relation(fields: [groupId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@ -8,8 +8,8 @@ model GroupLedgerAccount { // ledgerAccount LedgerAccount @relation(fields: [ledgerAccountId], references: [id], onDelete: Cascade, onUpdate: Cascade) // ledgerAccountId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Index on IDs for faster look up // @@index([groupId]) @@ -158,7 +158,7 @@ model Payment { // The text displayed on the bank statement descriptionShort String? // The life cycle state of this payment - state PaymentState + state PaymentState @default(PENDING) // The responsible provider for this payment provider PaymentProvider // Only one of the following relations may be set diff --git a/src/services/ledger/payments/operations.ts b/src/services/ledger/payments/operations.ts index 108d89056..0c4c500e9 100644 --- a/src/services/ledger/payments/operations.ts +++ b/src/services/ledger/payments/operations.ts @@ -14,42 +14,35 @@ export const paymentOperations = { */ create: defineOperation({ 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(), - }), - ]), - ), - operation: async ({ prisma, params }) => { - const { details = {}, ...paymentData } = params - + paramsSchema: z.object({ + funds: z.number(), + descriptionLong: z.string().optional(), + descriptionShort: z.string().optional(), + provider: z.nativeEnum(PaymentProvider), + manualFees: z.number().nonnegative().optional(), + bankAccountNumber: z.string().optional(), + }), + operation: async ({ prisma, 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: details, - } : undefined, - manualPayment: params.provider === 'MANUAL' ? { - create: details, - } : undefined, + provider: params.provider, + funds: params.funds, + + ...(params.provider === 'STRIPE' && { + create: {}, + }), + + // Manual payments are special in that they automatically succeed + // and fees are determined manually by the user. + ...(params.provider === 'MANUAL' && { + state: 'SUCCEEDED', + fees: params.manualFees, + manualPayment: { + create: { + bankAccountNumber: params.bankAccountNumber, + }, + }, + }) }, include: { stripePayment: true, diff --git a/src/services/users/operations.ts b/src/services/users/operations.ts index 8fedd12ff..228e74ad7 100644 --- a/src/services/users/operations.ts +++ b/src/services/users/operations.ts @@ -51,7 +51,10 @@ export const userOperations = { }, }) - setTimeout(() => sendUserInvitationEmail(user), 1000) + // Don't send mail during testing. + if (process.env.NODE_ENV !== 'test') { + setTimeout(() => sendUserInvitationEmail(user), 1000) + } // The timeout is here to make sure the user is fully created before we send the email. // If we don't wait the validation token will be generated first, and will not be valid since // the user has changed after the token was generated. diff --git a/tests/services/ledger/ledgerTransactions.test.ts b/tests/services/ledger/ledgerTransactions.test.ts index 47085a3a4..be3cc0896 100644 --- a/tests/services/ledger/ledgerTransactions.test.ts +++ b/tests/services/ledger/ledgerTransactions.test.ts @@ -55,9 +55,7 @@ describe('ledger transactions', () => { params: { funds: INITIAL_BALANCE.amount, provider: 'MANUAL', - details: { - fees: INITIAL_BALANCE.fees, - }, + manualFees: INITIAL_BALANCE.fees, }, }) diff --git a/tests/services/ledger/payouts.test.ts b/tests/services/ledger/payouts.test.ts deleted file mode 100644 index ce1e5cd08..000000000 --- a/tests/services/ledger/payouts.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 66e43a917..000000000 --- a/tests/services/ledger/transactions.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, test } from '@jest/globals' - -describe('transactions', () => { - test('nothing', () => { - - }) -}) From 8c209117ff6ad4e1f59e0beb6369508854d84a8a Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 15:06:57 +0100 Subject: [PATCH 52/62] style: linting --- src/services/ledger/payments/operations.ts | 50 +++++++++++----------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/services/ledger/payments/operations.ts b/src/services/ledger/payments/operations.ts index 0c4c500e9..6629896c6 100644 --- a/src/services/ledger/payments/operations.ts +++ b/src/services/ledger/payments/operations.ts @@ -22,34 +22,32 @@ export const paymentOperations = { manualFees: z.number().nonnegative().optional(), bankAccountNumber: z.string().optional(), }), - operation: async ({ prisma, params }) => { - return prisma.payment.create({ - data: { - provider: params.provider, - funds: params.funds, - - ...(params.provider === 'STRIPE' && { - create: {}, - }), + operation: async ({ prisma, params }) => prisma.payment.create({ + data: { + provider: params.provider, + funds: params.funds, - // Manual payments are special in that they automatically succeed - // and fees are determined manually by the user. - ...(params.provider === 'MANUAL' && { - state: 'SUCCEEDED', - fees: params.manualFees, - manualPayment: { - create: { - bankAccountNumber: params.bankAccountNumber, - }, + ...(params.provider === 'STRIPE' && { + create: {}, + }), + + // Manual payments are special in that they automatically succeed + // and fees are determined manually by the user. + ...(params.provider === 'MANUAL' && { + state: 'SUCCEEDED', + fees: params.manualFees, + manualPayment: { + create: { + bankAccountNumber: params.bankAccountNumber, }, - }) - }, - include: { - stripePayment: true, - manualPayment: true, - } - }) - }, + }, + }) + }, + include: { + stripePayment: true, + manualPayment: true, + } + }), }), /** From b0b685ce54e57c2390ba82249ceda15095b4d541 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 15:35:54 +0100 Subject: [PATCH 53/62] fix: make console errors from next auth --- src/auth/nextAuth/authOptions.ts | 14 ++++++++++++++ src/auth/session/ServerSession.ts | 20 ++++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/auth/nextAuth/authOptions.ts b/src/auth/nextAuth/authOptions.ts index 8ac32c787..9ec4b6404 100644 --- a/src/auth/nextAuth/authOptions.ts +++ b/src/auth/nextAuth/authOptions.ts @@ -184,4 +184,18 @@ export const authOptions: AuthOptions = { newUser: '/register', }, adapter: VevenAdapter(prisma), + logger: { + // TODO: Before going to production we should use the proper logger here! + error(code, metadata) { + // Overwrite to use warnings in stead to reduce + // noise from invalid JWT in development. + console.warn('NextAuth Error:', code, metadata) + }, + warn(code) { + console.warn('NextAuth Warning:', code) + }, + debug(code, metadata) { + console.debug('NextAuth Debug:', code, metadata) + }, + }, } diff --git a/src/auth/session/ServerSession.ts b/src/auth/session/ServerSession.ts index 13fe4ae99..f9dfb5f9e 100644 --- a/src/auth/session/ServerSession.ts +++ b/src/auth/session/ServerSession.ts @@ -9,14 +9,18 @@ import { getServerSession as getSessionNextAuth } from 'next-auth' export class ServerSession extends Session { public static async fromNextAuth(): Promise | Session<'HAS_USER'>> { - const { - user = null, - permissions = await permissionOperations.readDefaultPermissions({ - bypassAuth: true, - }), - memberships = [], - } = await getSessionNextAuth(authOptions) ?? {} - return new Session({ user, permissions, memberships }) + const session = await getSessionNextAuth(authOptions) + + if (!session) { + const defaultPermissions = await permissionOperations.readDefaultPermissions({ bypassAuth: true }) + return ServerSession.fromDefaultPermissions(defaultPermissions) + } + + return new Session({ + user: session.user, + permissions: session.permissions, + memberships: session.memberships + }) } /** From 7d40b4e298f78f1f37bf1a639977d458ba5c436c Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 16:17:34 +0100 Subject: [PATCH 54/62] feat: remember pop uip in url --- src/app/_components/PopUp/PopUp.tsx | 73 ++++++++++++++++++++++++++--- src/app/layout.tsx | 1 + 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index 53db2176f..d97d26c8b 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -1,4 +1,5 @@ 'use client' + import styles from './PopUp.module.scss' import useKeyPress from '@/hooks/useKeyPress' import { PopUpContext } from '@/contexts/PopUp' @@ -8,26 +9,52 @@ 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' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' export type PropTypes = { children: ReactNode, - PopUpKey: PopUpKeyType, customShowButton?: (open: () => void) => ReactNode, showButtonContent?: ReactNode, showButtonClass?: string, showButtonStyle?: CSSProperties, -} + storeInUrl?: boolean, +} & ( + // The prop `PopUpKey` should actually be `popUpKey`. + // For backwards compatibility, we support both for now. + // TODO: Fully remove PopUpKey. + | { + /** @deprecated Use `popUpKey` instead. */ + PopUpKey: PopUpKeyType, + popUpKey?: never + } | { + popUpKey: PopUpKeyType, + /** @deprecated Use `popUpKey` instead. */ + PopUpKey?: never + } +) export default function PopUp({ PopUpKey, + popUpKey, children, customShowButton, showButtonContent, showButtonClass, showButtonStyle, + storeInUrl = false, }: PropTypes) { + const router = useRouter() + const searchParams = useSearchParams() + const pathName = usePathname() const [isOpen, setIsOpen] = useState(false) + const effectivePopUpKey = popUpKey ?? PopUpKey + + // This should never happen due to static type checks, but + // TypeScript isn't smart enough to see that in this case. + if (!effectivePopUpKey) { + throw new Error('The popUpKey prop is required for the PopUp component') + } const popUpContext = useContext(PopUpContext) useKeyPress('Escape', () => setIsOpen(false)) @@ -38,14 +65,14 @@ export default function PopUp({ useEffect(() => { if (isOpen) { - popUpContext.teleport(contentRef.current, PopUpKey) + popUpContext.teleport(contentRef.current, effectivePopUpKey) } else { - popUpContext.remove(PopUpKey) + popUpContext.remove(effectivePopUpKey) } }, [isOpen]) useEffect(() => { - if (popUpContext.keyOfCurrentNode !== PopUpKey) { + if (popUpContext.keyOfCurrentNode !== effectivePopUpKey) { setIsOpen(false) } }, [popUpContext.keyOfCurrentNode]) @@ -66,10 +93,44 @@ export default function PopUp({
) if (isOpen) { - popUpContext.teleport(contentRef.current, PopUpKey) + popUpContext.teleport(contentRef.current, effectivePopUpKey) } }, [children]) + useEffect(() => { + if (!storeInUrl) return + + const params = new URLSearchParams(searchParams.toString()) + const keyInUrl = params.get('pop-up-key') === effectivePopUpKey + + if (keyInUrl && !isOpen) { + setIsOpen(true) + } + }, [storeInUrl, isOpen, searchParams, effectivePopUpKey]) + + useEffect(() => { + if (!storeInUrl) return + + const params = new URLSearchParams(searchParams.toString()) + const keyInUrl = params.get('pop-up-key') === effectivePopUpKey + + if (isOpen) { + // Set the pop-up key in the URL to indicate that this pop-up is open. + // There should only be one pop-up open at a time, so we can just set it directly. + params.set('pop-up-key', String(effectivePopUpKey)) + } else if (keyInUrl) { + // We check if the key is our key to avoid removing another pop-up's key. + params.delete('pop-up-key') + } + + const newUrl = pathName + '?' + params.toString() + const oldUrl = pathName + '?' + searchParams.toString() + + if (newUrl !== oldUrl) { + router.replace(pathName + '?' + params.toString()) + } + }, [storeInUrl, pathName, searchParams, isOpen, effectivePopUpKey]) + const handleOpening = useCallback(() => { setIsOpen(true) }, []) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 63b64e64e..0cf1330ca 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,6 +35,7 @@ type PropTypes = { export default async function RootLayout({ children }: PropTypes) { const session = await getServerSession(authOptions) + const defaultPermissionsRes = await readDefaultPermissionsAction() const defaultPermissions = defaultPermissionsRes.success ? defaultPermissionsRes.data : [] const profile = session?.user ? From c852574469edb23d33a63dff548a924a64e767eb Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 16:29:20 +0100 Subject: [PATCH 55/62] style: linting --- src/app/_components/PopUp/PopUp.tsx | 10 +++++----- src/auth/session/ServerSession.ts | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index d97d26c8b..8d3278e38 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -7,9 +7,9 @@ import useClickOutsideRef from '@/hooks/useClickOutsideRef' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faXmark } from '@fortawesome/free-solid-svg-icons' import { useContext, useEffect, useState, useRef, useCallback } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import type { ReactNode, CSSProperties } from 'react' import type { PopUpKeyType } from '@/contexts/PopUp' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' export type PropTypes = { children: ReactNode, @@ -22,7 +22,7 @@ export type PropTypes = { // The prop `PopUpKey` should actually be `popUpKey`. // For backwards compatibility, we support both for now. // TODO: Fully remove PopUpKey. - | { + { /** @deprecated Use `popUpKey` instead. */ PopUpKey: PopUpKeyType, popUpKey?: never @@ -123,11 +123,11 @@ export default function PopUp({ params.delete('pop-up-key') } - const newUrl = pathName + '?' + params.toString() - const oldUrl = pathName + '?' + searchParams.toString() + const newUrl = `${pathName}?${params.toString()}` + const oldUrl = `${pathName}?${searchParams.toString()}` if (newUrl !== oldUrl) { - router.replace(pathName + '?' + params.toString()) + router.replace(`${pathName}?${params.toString()}`) } }, [storeInUrl, pathName, searchParams, isOpen, effectivePopUpKey]) diff --git a/src/auth/session/ServerSession.ts b/src/auth/session/ServerSession.ts index f9dfb5f9e..c820e0344 100644 --- a/src/auth/session/ServerSession.ts +++ b/src/auth/session/ServerSession.ts @@ -16,10 +16,10 @@ export class ServerSession extends Se return ServerSession.fromDefaultPermissions(defaultPermissions) } - return new Session({ - user: session.user, - permissions: session.permissions, - memberships: session.memberships + return new Session({ + user: session.user, + permissions: session.permissions, + memberships: session.memberships, }) } From dd97ef80c5b4c622c2c075acc42a087cdddee4ae Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 17:39:24 +0100 Subject: [PATCH 56/62] fix: pop up component prop type --- src/app/_components/PopUp/PopUp.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index 8d3278e38..d2b86e6a6 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -18,20 +18,13 @@ export type PropTypes = { showButtonClass?: string, showButtonStyle?: CSSProperties, storeInUrl?: boolean, -} & ( // The prop `PopUpKey` should actually be `popUpKey`. // For backwards compatibility, we support both for now. // TODO: Fully remove PopUpKey. - { - /** @deprecated Use `popUpKey` instead. */ - PopUpKey: PopUpKeyType, - popUpKey?: never - } | { - popUpKey: PopUpKeyType, - /** @deprecated Use `popUpKey` instead. */ - PopUpKey?: never - } -) + /** @deprecated Use `popUpKey` instead. */ + PopUpKey?: PopUpKeyType, + popUpKey?: PopUpKeyType +} export default function PopUp({ PopUpKey, From e66a1c6776775187cb954f6d656ff4d235321ecc Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 19:18:06 +0100 Subject: [PATCH 57/62] fix: use useEffectEvent in pop up --- src/app/_components/PopUp/PopUp.tsx | 14 +++++++++++--- .../users/[username]/(user-admin)/account/page.tsx | 5 ++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/app/_components/PopUp/PopUp.tsx b/src/app/_components/PopUp/PopUp.tsx index fa4303ed5..63df288d0 100644 --- a/src/app/_components/PopUp/PopUp.tsx +++ b/src/app/_components/PopUp/PopUp.tsx @@ -86,7 +86,7 @@ export default function PopUp({ } }, [children, isOpen, popUpKey, teleport, ref]) - useEffect(() => { + const handleSearchParamsChange = useEffectEvent(() => { if (!storeInUrl) return const params = new URLSearchParams(searchParams.toString()) @@ -95,9 +95,13 @@ export default function PopUp({ if (keyInUrl && !isOpen) { setIsOpen(true) } - }, [storeInUrl, isOpen, searchParams, popUpKey]) + }) useEffect(() => { + handleSearchParamsChange() + }, [searchParams]) + + const handleIsOpenChange = useEffectEvent(() => { if (!storeInUrl) return const params = new URLSearchParams(searchParams.toString()) @@ -118,7 +122,11 @@ export default function PopUp({ if (newUrl !== oldUrl) { router.replace(`${pathName}?${params.toString()}`) } - }, [storeInUrl, pathName, searchParams, isOpen, popUpKey]) + }) + + useEffect(() => { + handleIsOpenChange() + }, [isOpen]) const handleOpening = useCallback(() => { setIsOpen(true) diff --git a/src/app/users/[username]/(user-admin)/account/page.tsx b/src/app/users/[username]/(user-admin)/account/page.tsx index 36d9b7e04..6e9143d9f 100644 --- a/src/app/users/[username]/(user-admin)/account/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/page.tsx @@ -1,15 +1,14 @@ -import { unwrapActionReturn } from '@/app/redirectToErrorPage' +import { redirectToErrorPage, unwrapActionReturn } from '@/app/redirectToErrorPage' import { readLedgerAccountAction } from '@/services/ledger/accounts/actions' import LedgerAccountOverview from '@/components/Ledger/Accounts/LedgerAccountOverviewCard' import LedgerAccountPaymentMethods from '@/components/Ledger/Accounts/LedgerAccountPaymentMethodsCard' import LedgerAccountTransactionSummary from '@/components/Ledger/Accounts/LedgerAccountTransactionSummaryCard' import { ServerSession } from '@/auth/session/ServerSession' -import { unauthorized } from 'next/navigation' export default async function Account() { const session = await ServerSession.fromNextAuth() - if (!session.user) unauthorized() + if (!session.user) redirectToErrorPage('UNAUTHORIZED') const ledgerAccount = unwrapActionReturn(await readLedgerAccountAction({ params: { userId: session.user.id } })) From 3d214e70af06a8779417bcca0fe7c61450bb725e Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 20:37:59 +0100 Subject: [PATCH 58/62] refactor: remove unused checkout modal for now --- .../Ledger/Modals/CheckoutModal.module.scss | 7 - .../Ledger/Modals/CheckoutModal.tsx | 236 ------------------ 2 files changed, 243 deletions(-) delete mode 100644 src/app/_components/Ledger/Modals/CheckoutModal.module.scss delete mode 100644 src/app/_components/Ledger/Modals/CheckoutModal.tsx diff --git a/src/app/_components/Ledger/Modals/CheckoutModal.module.scss b/src/app/_components/Ledger/Modals/CheckoutModal.module.scss deleted file mode 100644 index dae5e9524..000000000 --- a/src/app/_components/Ledger/Modals/CheckoutModal.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.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 deleted file mode 100644 index d959db8ee..000000000 --- a/src/app/_components/Ledger/Modals/CheckoutModal.tsx +++ /dev/null @@ -1,236 +0,0 @@ -'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, useRef } from 'react' -import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/types' -import type { PaymentProvider } from '@/prisma-generated-pn-types' -import type { StripePaymentRef } from '@/components/Stripe/StripePayment' -import type { ActionReturn } from '@/services/actionTypes' - -const StripeProvider = lazy(() => import('@/components/Stripe/StripeProvider')) -const StripePayment = lazy(() => import('@/components/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') { - if (!stripePaymentRef?.current) { - return createActionError('BAD DATA', 'Stripe er ikke initialisert enda.') - } - - const error = await stripePaymentRef.current.submit() - - if (error) { - return createActionError('BAD DATA', error) - } - } - - // TODO: Figure out why this is a linter error - // eslint-disable-next-line - 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?.clientSecret) { - return createActionError('BAD DATA', 'Ugyldig betalingsdata fra server.') - } - - stripePaymentRef.current?.confirmPayment(transaction.payment.stripePayment.clientSecret) - } - - return { success: true, data: undefined } - } - - 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}
-// )} -//
-// } From e072e7d927f87675cdbd84d3fc43ce703ac438bd Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Mon, 19 Jan 2026 21:13:54 +0100 Subject: [PATCH 59/62] fix: minor issues with setting manual fees --- .../Ledger/Modals/DepositModal.tsx | 4 +-- .../_components/Ledger/Modals/PayoutModal.tsx | 2 ++ .../LedgerTransactionList.module.scss | 5 ++++ .../Transactions/LedgerTransactionList.tsx | 20 +++++++++++++- .../LedgerTransactionRow.module.scss | 11 ++------ .../Transactions/LedgerTransactionRow.tsx | 27 ++++++++++++------- .../PagingWrappers/EndlessScroll.tsx | 6 +++-- .../account/transactions/page.tsx | 2 +- src/lib/currency/convert.ts | 7 +++-- src/services/ledger/movements/operations.ts | 6 ++++- src/services/ledger/payments/operations.ts | 2 +- .../ledger/transactions/operations.ts | 3 ++- 12 files changed, 65 insertions(+), 30 deletions(-) diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 0c0ab5cb8..4182878d5 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -67,7 +67,7 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec } // Call the server action to create the deposit - const createResult = await createDepositAction({ params: { ledgerAccountId, funds, provider: selectedProvider } }) + const createResult = await createDepositAction({ params: { ledgerAccountId, funds, manualFees, provider: selectedProvider } }) if (!createResult.success) return createResult // The returned transaction should have a payment @@ -90,7 +90,7 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec >

Nytt innskudd

-
+ + transaction => + } + wrapper={children => + + + + + + + + + {showFees && } + + + + {children} + +
DatoBeskrivelseStatusBeløpSaldoendringGebyrendring
} /> {/* 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 index aab02cce2..f3e91839f 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.module.scss @@ -1,12 +1,5 @@ @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; +.rightAlign { + text-align: right; } \ No newline at end of file diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx index fa089eb34..850e67aa5 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -4,18 +4,25 @@ import type { ExpandedLedgerTransaction } from '@/services/ledger/transactions/t type Props = { transaction: ExpandedLedgerTransaction, + accountId: number, 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) +export default function LedgerTransactionRow({ transaction, accountId, showFees }: Props) { + const totalFunds = ( + transaction.ledgerEntries?.reduce((sum, entry) => sum + Math.abs(entry.funds), 0) + + Math.abs(transaction.payment?.funds ?? 0) + ) / 2 - return -

{transaction.createdAt.toLocaleString()}

-

{displayAmount(totalFunds)}

- {showFees &&

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

} -

{transaction.purpose}

-

{transaction.state}

-
+ const fundsChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.funds ?? null + const feesChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.fees ?? null + + return + {transaction.createdAt.toLocaleString()} + {transaction.purpose} + {transaction.state} + {displayAmount(totalFunds)} + {fundsChange !== null ? displayAmount(fundsChange) : '-'} + {showFees && {feesChange !== null ? displayAmount(feesChange) : '-'}} + } diff --git a/src/app/_components/PagingWrappers/EndlessScroll.tsx b/src/app/_components/PagingWrappers/EndlessScroll.tsx index a5fc8fead..e803f9a24 100644 --- a/src/app/_components/PagingWrappers/EndlessScroll.tsx +++ b/src/app/_components/PagingWrappers/EndlessScroll.tsx @@ -16,13 +16,15 @@ import type { PagingContext } from '@/contexts/paging/PagingGenerator' type PropTypes = { pagingContext: PagingContext, renderer: (data: Data, i: number) => React.ReactNode, + wrapper?: (children: React.ReactNode) => React.ReactNode, loadingInfoClassName?: string, } export default function EndlessScroll({ pagingContext, loadingInfoClassName, - renderer + renderer, + wrapper = children => <>{children}, }: PropTypes) { const context = useContext(pagingContext) @@ -71,7 +73,7 @@ export default function EndlessScroll - {renderedPageData} + {wrapper(renderedPageData)} { context.state.data.length === 0 diff --git a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx index eaadd7553..49c296651 100644 --- a/src/app/users/[username]/(user-admin)/account/transactions/page.tsx +++ b/src/app/users/[username]/(user-admin)/account/transactions/page.tsx @@ -9,5 +9,5 @@ export default async function Transactions() { const account = { id: 2 } - return + return } diff --git a/src/lib/currency/convert.ts b/src/lib/currency/convert.ts index 81f3c6792..52de730d3 100644 --- a/src/lib/currency/convert.ts +++ b/src/lib/currency/convert.ts @@ -10,10 +10,13 @@ export function convertAmount(amount: string | number): number { return Math.round(Number(amount) * 100) } -export function displayAmount(amount: number, short: boolean = true): string { +export function displayAmount(amount: number, short: boolean = true, withSign: boolean = false): string { const convertedamount = amount / 100 const amountString = convertedamount.toFixed(2) if (short) return amountString - return `${amountString} ${currencySymbol}` + const sign = withSign && amount !== 0 + ? convertedamount > 0 ? '+' : '-' + : '' + return `${sign}${amountString} ${currencySymbol}` } diff --git a/src/services/ledger/movements/operations.ts b/src/services/ledger/movements/operations.ts index de8f329ef..49245ad35 100644 --- a/src/services/ledger/movements/operations.ts +++ b/src/services/ledger/movements/operations.ts @@ -25,7 +25,8 @@ export const ledgerMovementOperations = { paramsSchema: z.object({ ledgerAccountId: z.number(), provider: z.nativeEnum(PaymentProvider), - funds: z.coerce.number().positive(), + funds: z.coerce.number().nonnegative(), + manualFees: z.coerce.number().nonnegative().default(0), }), operation: async ({ prisma, params }) => { const transaction = await prisma.$transaction(async tx => { @@ -33,6 +34,7 @@ export const ledgerMovementOperations = { params: { provider: params.provider, funds: params.funds, + manualFees: params.manualFees, descriptionLong: 'Innskudd til veven', descriptionShort: 'Innskudd', }, @@ -86,6 +88,7 @@ export const ledgerMovementOperations = { descriptionLong: 'Utbetaling fra veven', descriptionShort: 'Utbetaling', funds: -params.funds, + manualFees: -params.fees, }, prisma: tx, }) @@ -96,6 +99,7 @@ export const ledgerMovementOperations = { ledgerEntries: [{ ledgerAccountId: params.ledgerAccountId, funds: -params.funds, + fees: -params.fees, }], paymentId: payment.id, }, diff --git a/src/services/ledger/payments/operations.ts b/src/services/ledger/payments/operations.ts index 3e7a110e5..26dcef628 100644 --- a/src/services/ledger/payments/operations.ts +++ b/src/services/ledger/payments/operations.ts @@ -19,7 +19,7 @@ export const paymentOperations = { descriptionLong: z.string().optional(), descriptionShort: z.string().optional(), provider: z.nativeEnum(PaymentProvider), - manualFees: z.number().nonnegative().optional(), + manualFees: z.number().optional(), bankAccountNumber: z.string().optional(), }), operation: async ({ prisma, params }) => prisma.payment.create({ diff --git a/src/services/ledger/transactions/operations.ts b/src/services/ledger/transactions/operations.ts index 82265b556..39899a172 100644 --- a/src/services/ledger/transactions/operations.ts +++ b/src/services/ledger/transactions/operations.ts @@ -180,6 +180,7 @@ export const ledgerTransactionOperations = { purpose: z.nativeEnum(LedgerTransactionPurpose), ledgerEntries: z.object({ funds: z.number(), + fees: z.number().optional(), ledgerAccountId: z.number(), }).array(), paymentId: z.number().optional(), @@ -205,7 +206,7 @@ export const ledgerTransactionOperations = { const fees = calculateDebitFees(params.ledgerEntries, balances) const entries = params.ledgerEntries.map(entry => ({ ...entry, - fees: fees[entry.ledgerAccountId] ?? null + fees: entry.fees ?? fees[entry.ledgerAccountId] ?? null })) const { id } = await prisma.ledgerTransaction.create({ From 5560f20621265c66c1967ad59a2a1c4915a80e50 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 21 Jan 2026 19:35:16 +0100 Subject: [PATCH 60/62] style: linting --- .../Ledger/Modals/DepositModal.tsx | 4 ++- .../Transactions/LedgerTransactionList.tsx | 27 +++++++++++-------- .../Transactions/LedgerTransactionRow.tsx | 2 +- src/lib/currency/convert.ts | 11 ++++---- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/app/_components/Ledger/Modals/DepositModal.tsx b/src/app/_components/Ledger/Modals/DepositModal.tsx index 4182878d5..48d5c80db 100644 --- a/src/app/_components/Ledger/Modals/DepositModal.tsx +++ b/src/app/_components/Ledger/Modals/DepositModal.tsx @@ -67,7 +67,9 @@ export default function DepositModal({ ledgerAccountId, customerSessionClientSec } // Call the server action to create the deposit - const createResult = await createDepositAction({ params: { ledgerAccountId, funds, manualFees, provider: selectedProvider } }) + const createResult = await createDepositAction({ + params: { ledgerAccountId, funds, manualFees, provider: selectedProvider } + }) if (!createResult.success) return createResult // The returned transaction should have a payment diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx index d7a54ca6d..64be1086a 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionList.tsx @@ -18,20 +18,25 @@ export default function TransactionList({ accountId, showFees }: Props) { + transaction => } wrapper={children => - - - - - - - - {showFees && } - - + + + + + + + + {showFees && } + + {children} diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx index 850e67aa5..a662af2bf 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -16,7 +16,7 @@ export default function LedgerTransactionRow({ transaction, accountId, showFees const fundsChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.funds ?? null const feesChange = transaction.ledgerEntries.find(entry => entry.ledgerAccountId === accountId)?.fees ?? null - + return diff --git a/src/lib/currency/convert.ts b/src/lib/currency/convert.ts index 52de730d3..0672af4db 100644 --- a/src/lib/currency/convert.ts +++ b/src/lib/currency/convert.ts @@ -11,12 +11,11 @@ export function convertAmount(amount: string | number): number { } export function displayAmount(amount: number, short: boolean = true, withSign: boolean = false): string { - const convertedamount = amount / 100 - const amountString = convertedamount.toFixed(2) + const convertedAmount = amount / 100 + const amountString = convertedAmount.toFixed(2) if (short) return amountString - const sign = withSign && amount !== 0 - ? convertedamount > 0 ? '+' : '-' - : '' - return `${sign}${amountString} ${currencySymbol}` + const sign = convertedAmount > 0 ? '+' : '-' + + return `${withSign && convertedAmount !== 0 ? sign : ''}${amountString} ${currencySymbol}` } From 4ec7725b10b496f2e20e03ad70db1cfe2bff8359 Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 21 Jan 2026 19:35:30 +0100 Subject: [PATCH 61/62] chore: update readme with test command and add lint fix command --- README.md | 15 ++++++++++++++- package.json | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 40b63ad81..bb1944914 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ npm run lint To auto-fix linting errors run ```bash -npm run lint -- --fix +npm run lint:fix ``` ## Migration from omegaweb basic @@ -92,3 +92,16 @@ npm run dobbelOmega:run ``` If you are connected to our test database on openStack, make sure to be on the ntnu network to be able to connect. + +## Testing + +To run the tests run +```bash +npm run docker:test +``` + +The tests can also be run outside of docker using +```bash +npm run test +``` +but this requires starting a database manually. diff --git a/package.json b/package.json index 0cb79280f..a60a5ac6f 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "lint:fix": "eslint --fix --quiet", "test": "IGNORE_SERVER_ONLY=true jest", "docker:dev": "./development-entrypoint.sh", "docker:test": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from projectnext --attach projectnext --no-log-prefix", From 2629005a0d2e13f44cd4aa532909c65463ef2eaf Mon Sep 17 00:00:00 2001 From: Paulius Juzenas Date: Wed, 21 Jan 2026 19:53:19 +0100 Subject: [PATCH 62/62] style: linting --- .../Ledger/Transactions/LedgerTransactionRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx index a662af2bf..e8b2aaa3a 100644 --- a/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx +++ b/src/app/_components/Ledger/Transactions/LedgerTransactionRow.tsx @@ -21,8 +21,8 @@ export default function LedgerTransactionRow({ transaction, accountId, showFees - - - {showFees && } + + + {showFees && } }
DatoBeskrivelseStatusBeløpSaldoendringGebyrendring
DatoBeskrivelseStatusBeløpSaldoendringGebyrendring
{transaction.createdAt.toLocaleString()} {transaction.purpose}{transaction.createdAt.toLocaleString()} {transaction.purpose} {transaction.state}{displayAmount(totalFunds)}{fundsChange !== null ? displayAmount(fundsChange) : '-'}{feesChange !== null ? displayAmount(feesChange) : '-'}{displayAmount(totalFunds)}{fundsChange !== null ? displayAmount(fundsChange) : '-'}{feesChange !== null ? displayAmount(feesChange) : '-'}