mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
changed to direct charge and changed designs
This commit is contained in:
parent
b5703aaf8e
commit
55d8838c0e
86 changed files with 5854 additions and 3349 deletions
617
package-lock.json
generated
617
package-lock.json
generated
|
|
@ -27,6 +27,8 @@
|
|||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@serwist/turbopack": "^9.5.12",
|
||||
"@stripe/connect-js": "^3.4.6",
|
||||
"@stripe/react-connect-js": "^3.4.4",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.116.0",
|
||||
|
|
@ -739,7 +741,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -756,7 +757,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -773,7 +773,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -790,7 +789,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -807,7 +805,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -824,7 +821,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -841,7 +837,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -858,7 +853,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -875,7 +869,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -892,7 +885,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -909,7 +901,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -926,7 +917,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -943,7 +933,6 @@
|
|||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -960,7 +949,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -977,7 +965,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -994,7 +981,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1011,7 +997,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1028,7 +1013,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1045,7 +1029,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1062,7 +1045,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1079,7 +1061,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1096,7 +1077,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1113,7 +1093,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1130,7 +1109,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1147,7 +1125,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1164,7 +1141,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2087,6 +2063,17 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/source-map": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -5980,6 +5967,23 @@
|
|||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/connect-js": {
|
||||
"version": "3.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/connect-js/-/connect-js-3.4.6.tgz",
|
||||
"integrity": "sha512-fuC/U/ONDBMKwTBxKAnEBQH+m0x+Tze62/bV1ZDLT7oRnsJgddEnRi7FzdwIxz9pmFZhOJpSaikLIBw9g/1o1w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/react-connect-js": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-connect-js/-/react-connect-js-3.4.4.tgz",
|
||||
"integrity": "sha512-LUtVcGwnkOU9SO1t6j/kAhc89tMCPpFzXz+LU+fhJkOVSDFy8lnidd+t0vNmkVvVU3o8wNVvWgUMxRHL/d1QhQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@stripe/connect-js": ">=3.4.6",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "9.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.8.0.tgz",
|
||||
|
|
@ -7336,7 +7340,6 @@
|
|||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json5": {
|
||||
|
|
@ -7350,7 +7353,6 @@
|
|||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
|
@ -7360,7 +7362,6 @@
|
|||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -7370,7 +7371,6 @@
|
|||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
|
@ -8170,6 +8170,181 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ast": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/helper-numbers": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/floating-point-hex-parser": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
||||
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-api-error": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
||||
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-buffer": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
||||
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-numbers": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
|
||||
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-wasm-bytecode": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
||||
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-wasm-section": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
|
||||
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
"@webassemblyjs/wasm-gen": "1.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/ieee754": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
|
||||
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/ieee754": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/leb128": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
|
||||
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/utf8": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
||||
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-edit": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
|
||||
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-section": "1.14.1",
|
||||
"@webassemblyjs/wasm-gen": "1.14.1",
|
||||
"@webassemblyjs/wasm-opt": "1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "1.14.1",
|
||||
"@webassemblyjs/wast-printer": "1.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-gen": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
|
||||
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
"@webassemblyjs/ieee754": "1.13.2",
|
||||
"@webassemblyjs/leb128": "1.13.2",
|
||||
"@webassemblyjs/utf8": "1.13.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-opt": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
|
||||
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
"@webassemblyjs/wasm-gen": "1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "1.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-parser": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
|
||||
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
"@webassemblyjs/ieee754": "1.13.2",
|
||||
"@webassemblyjs/leb128": "1.13.2",
|
||||
"@webassemblyjs/utf8": "1.13.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webassemblyjs/wast-printer": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
|
||||
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xtuc/long": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -8192,7 +8367,6 @@
|
|||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
|
|
@ -8237,6 +8411,48 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
|
|
@ -8862,6 +9078,13 @@
|
|||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
|
|
@ -8984,6 +9207,16 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chrome-trace-event": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
|
|
@ -9388,7 +9621,6 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
|
|
@ -10111,7 +10343,7 @@
|
|||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
@ -10153,7 +10385,7 @@
|
|||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.28.2.tgz",
|
||||
"integrity": "sha512-GccVwhv3mmOUVQHCQm2Ox/rby8n/EqUwvZxE6Pjfikrq/lWw9g9WX/u9EykWnpot3Ko6j426DgQdea2xWKIAQA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
|
|
@ -10767,6 +10999,23 @@
|
|||
"fast-string-truncated-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
|
||||
|
|
@ -11179,7 +11428,6 @@
|
|||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/graphql": {
|
||||
|
|
@ -11209,7 +11457,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -12154,6 +12401,37 @@
|
|||
"restructure": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
"supports-color": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-worker/node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
|
|
@ -12937,6 +13215,13 @@
|
|||
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
@ -12983,6 +13268,16 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
|
|
@ -13036,6 +13331,79 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minimizer-webpack-plugin": {
|
||||
"version": "5.11.0",
|
||||
"resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.11.0.tgz",
|
||||
"integrity": "sha512-2JVy2DY+iBtfRyrQB5cRM7lfurnJIi6jFTgLip/7FxDu0WxswrIRzr4fBySQ0X7HqM7BcwE28zm+HfQ5V6y4Cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"jest-worker": "^27.4.5",
|
||||
"schema-utils": "^4.3.3",
|
||||
"terser": "^5.51.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@minify-html/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@napi-rs/image": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/css": {
|
||||
"optional": true
|
||||
},
|
||||
"@swc/html": {
|
||||
"optional": true
|
||||
},
|
||||
"clean-css": {
|
||||
"optional": true
|
||||
},
|
||||
"cssnano": {
|
||||
"optional": true
|
||||
},
|
||||
"csso": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"html-minifier-terser": {
|
||||
"optional": true
|
||||
},
|
||||
"imagemin": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"postcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sharp": {
|
||||
"optional": true
|
||||
},
|
||||
"svgo": {
|
||||
"optional": true
|
||||
},
|
||||
"uglify-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
|
|
@ -14436,7 +14804,6 @@
|
|||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-phone-number-input": {
|
||||
|
|
@ -15072,6 +15439,63 @@
|
|||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.5.0.tgz",
|
||||
"integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"ajv-keywords": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv-keywords": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/semifies": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz",
|
||||
|
|
@ -15401,6 +15825,17 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-support": {
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
|
|
@ -15831,7 +16266,6 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -15862,6 +16296,32 @@
|
|||
"streamx": "^2.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.51.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
|
||||
"integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
"commander": "^2.20.0",
|
||||
"source-map-support": "~0.5.20"
|
||||
},
|
||||
"bin": {
|
||||
"terser": "bin/terser"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/terser/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
|
|
@ -16150,7 +16610,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -16207,7 +16667,6 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
|
|
@ -16626,6 +17085,19 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz",
|
||||
"integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
|
|
@ -16655,6 +17127,71 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.111.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.111.1.tgz",
|
||||
"integrity": "sha512-cNypaz0RP+S4cvQVi1M/3bRUkvNP0xZpL0TjFx+c6jg64VbxD+2weWDgY9UnjRI6dhZgtaj8DU76GGFcJ79xPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@webassemblyjs/ast": "^1.14.1",
|
||||
"@webassemblyjs/wasm-edit": "^1.14.1",
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.25.0",
|
||||
"es-module-lexer": "^2.1.0",
|
||||
"events": "^3.2.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"mime-db": "^1.54.0",
|
||||
"minimizer-webpack-plugin": "^5.7.0",
|
||||
"schema-utils": "^4.5.0",
|
||||
"tapable": "^2.3.0",
|
||||
"watchpack": "^2.5.2",
|
||||
"webpack-sources": "^3.5.1"
|
||||
},
|
||||
"bin": {
|
||||
"webpack": "bin/webpack.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"webpack-cli": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-sources": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz",
|
||||
"integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack/node_modules/enhanced-resolve": {
|
||||
"version": "5.25.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz",
|
||||
"integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -35,6 +35,8 @@
|
|||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@sentry/nextjs": "^10.69.0",
|
||||
"@serwist/turbopack": "^9.5.12",
|
||||
"@stripe/connect-js": "^3.4.6",
|
||||
"@stripe/react-connect-js": "^3.4.4",
|
||||
"@stripe/stripe-js": "^9.5.0",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "^2.116.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
-- Unterscheidet Buchungen mit aufgeschlagener Servicegebühr (Kunde zahlt
|
||||
-- Kurspreis + Gebühr, amountPaid enthält die Gebühr) von Altbuchungen mit
|
||||
-- prozentualer Fee (aus dem Kurspreis einbehalten, amountPaid = Kursanteil).
|
||||
-- Default false ist für den Bestand korrekt: alle vorhandenen Zeilen stammen
|
||||
-- aus dem prozentualen Modell.
|
||||
ALTER TABLE "CourseBooking" ADD COLUMN "platformFeeOnTop" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- Stripe-Transaktionsgebühr pro Buchung, die per Transfer-Reversal an das
|
||||
-- Studio weitergereicht wurde. NULL bei Altbuchungen (dort trug die Plattform
|
||||
-- die Gebühr) und bei Zahlungen, deren Abrechnung noch aussteht.
|
||||
ALTER TABLE "CourseBooking" ADD COLUMN "stripeFee" INTEGER;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- Umstellung auf Direct Charges: jede Zahlung liegt künftig auf dem
|
||||
-- Express-Account des Studios. Die Spalte merkt sich, auf welchem - Erstattungen
|
||||
-- und das Auslesen der Stripe-Gebühr brauchen diese Account-ID.
|
||||
-- NULL = Altbuchung als Destination Charge auf dem Plattformkonto.
|
||||
ALTER TABLE "CourseBooking" ADD COLUMN "stripeAccountId" TEXT;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Steuerliche Angaben für Kundenrechnungen. Kein Backfill: Bisher galt
|
||||
-- "keine USt-IdNr = Kleinunternehmer", was für regelbesteuerte Studios mit
|
||||
-- reiner Steuernummer falsch war. Jedes Studio legt seinen Status einmal
|
||||
-- bewusst fest; bis dahin werden keine Rechnungen erzeugt.
|
||||
ALTER TABLE "Business" ADD COLUMN "taxStatus" TEXT;
|
||||
ALTER TABLE "Business" ADD COLUMN "taxNumber" TEXT;
|
||||
ALTER TABLE "Business" ADD COLUMN "vatRate" INTEGER;
|
||||
ALTER TABLE "Business" ADD COLUMN "taxExemptionNote" TEXT;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- Rechnungsadresse pro Buchung, im Stripe-Checkout erfasst (Pflichtfeld).
|
||||
-- Ältere Buchungen haben keine; dort nutzt die Rechnung weiter die
|
||||
-- Profiladresse des Kunden.
|
||||
ALTER TABLE "CourseBooking" ADD COLUMN "billingAddress" JSONB;
|
||||
|
|
@ -106,6 +106,14 @@ model Business {
|
|||
email String
|
||||
title String
|
||||
ustId String?
|
||||
// Steuerliche Angaben für die Rechnungen an die Kunden des Studios
|
||||
// (§ 14 UStG, § 33 UStDV). Bewusst ohne Standardwert: ob Umsatzsteuer
|
||||
// ausgewiesen werden muss, lässt sich aus keinem anderen Feld ableiten.
|
||||
// Solange taxStatus null ist, erstellt Kumasy keine Rechnungen.
|
||||
taxStatus String? // "regular" | "small_business" (§ 19 UStG) | "exempt"
|
||||
taxNumber String? // Steuernummer vom Finanzamt, z.B. 12/345/67890
|
||||
vatRate Int? // Umsatzsteuersatz für Kurse in Prozent (19 oder 7), nur bei "regular"
|
||||
taxExemptionNote String? // Grund der Steuerbefreiung für die Rechnung, nur bei "exempt"
|
||||
banking String?
|
||||
plan String?
|
||||
subscription Json?
|
||||
|
|
@ -204,8 +212,12 @@ model CourseBooking {
|
|||
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
|
||||
stripeSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
billingAddress Json? // Rechnungsadresse, die der Kunde im Stripe-Checkout angegeben hat - als Momentaufnahme pro Buchung, damit eine Rechnung sich nicht ändert, wenn jemand später umzieht. Siehe src/lib/billing-address.ts.
|
||||
stripeAccountId String? // Express-Account des Studios, auf dem die Zahlung liegt (Direct Charge). null = Altbuchung als Destination Charge auf dem Plattformkonto - Erstattungen laufen dann mit reverse_transfer.
|
||||
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
||||
platformFee Int? // unsere Service-Fee in Cents
|
||||
stripeFee Int? // Stripe-Gebühr in Cents, die Stripe bei der Zahlung vom Studio abgezogen hat (Direct Charge). null = noch nicht abgerechnet, bzw. bei Altbuchungen: Gebühr trug die Plattform.
|
||||
platformFeeOnTop Boolean @default(false) // true = Gebühr wurde dem Kunden ZUSÄTZLICH berechnet, amountPaid enthält sie. false = Altbuchung, bei der die prozentuale Fee aus dem Kurspreis einbehalten wurde.
|
||||
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
|
||||
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
|
||||
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
|
||||
|
|
|
|||
|
|
@ -5,9 +5,21 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
|
|||
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
||||
import CourseView from "@/src/modules/courses/ui/views/course-view";
|
||||
import CoursesLoading from "./loading";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import {
|
||||
addDaysToKey,
|
||||
berlinDayEnd,
|
||||
berlinDayStart,
|
||||
dayKey,
|
||||
} from "@/src/modules/courses/lib/course-days";
|
||||
|
||||
const Course = async () => {
|
||||
interface CoursesPageProps {
|
||||
searchParams: Promise<{ highlight?: string }>;
|
||||
}
|
||||
|
||||
const Course = async ({ searchParams }: CoursesPageProps) => {
|
||||
const userData = await requireManagerOrPermission((p) => p.courses.view);
|
||||
const { highlight } = await searchParams;
|
||||
const canCreate =
|
||||
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.create);
|
||||
const canEdit =
|
||||
|
|
@ -15,15 +27,26 @@ const Course = async () => {
|
|||
const canDelete =
|
||||
isManager(userData) || (isEmployee(userData) && userData.permissions.courses.delete);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const twoWeeksAgo = new Date(today);
|
||||
twoWeeksAgo.setDate(today.getDate() - 14);
|
||||
const twoWeeksLater = new Date(today);
|
||||
twoWeeksLater.setDate(today.getDate() + 14);
|
||||
// Ganze Berliner Tage: heute -14 bis einschließlich heute +14. "Mehr laden"
|
||||
// schließt lückenlos an das Ende an.
|
||||
const todayKey = dayKey(new Date());
|
||||
const windowFrom = berlinDayStart(addDaysToKey(todayKey, -14));
|
||||
let windowTo = berlinDayEnd(addDaysToKey(todayKey, 14));
|
||||
|
||||
// Kommt der Manager gerade vom Anlegen/Bearbeiten eines Kurses, muss der
|
||||
// im Fenster liegen - sonst wirkt es, als wäre er nicht gespeichert worden.
|
||||
if (highlight) {
|
||||
const highlighted = await prisma.course.findUnique({
|
||||
where: { id: highlight },
|
||||
select: { date: true },
|
||||
});
|
||||
if (highlighted && highlighted.date > windowTo) {
|
||||
windowTo = berlinDayEnd(dayKey(highlighted.date));
|
||||
}
|
||||
}
|
||||
|
||||
const [coursesResult, roomsResult, trainersResult] = await Promise.all([
|
||||
getMyCourses({ dateFrom: twoWeeksAgo, dateTo: twoWeeksLater }),
|
||||
getMyCourses({ dateFrom: windowFrom, dateTo: windowTo }),
|
||||
getMyRooms(),
|
||||
getMyTrainers(),
|
||||
]);
|
||||
|
|
@ -48,8 +71,9 @@ const Course = async () => {
|
|||
<Suspense fallback={<CoursesLoading />}>
|
||||
<CourseView
|
||||
initialCourses={initialCourses}
|
||||
initialDateFrom={twoWeeksAgo}
|
||||
initialDateTo={twoWeeksLater}
|
||||
initialDateFrom={windowFrom}
|
||||
initialDateTo={windowTo}
|
||||
highlightCourseId={highlight ?? null}
|
||||
roomsMap={roomsMap}
|
||||
trainersMap={trainersMap}
|
||||
canCreate={canCreate}
|
||||
|
|
|
|||
74
src/app/(main)/zahlungen/auszahlungen/page.tsx
Normal file
74
src/app/(main)/zahlungen/auszahlungen/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import StripePayoutsPanel from "@/src/modules/zahlungen/ui/components/stripe-payouts-panel";
|
||||
|
||||
interface AuszahlungenPageProps {
|
||||
searchParams: Promise<{ business?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auszahlungen, Bankverbindung und Zahlungsanfechtungen des Studios - als
|
||||
* eingebettete Stripe-Komponenten, damit niemand ins Stripe-Dashboard muss.
|
||||
*/
|
||||
export default async function AuszahlungenPage({ searchParams }: AuszahlungenPageProps) {
|
||||
const manager = await requireManager();
|
||||
const { business: requestedBusinessId } = await searchParams;
|
||||
|
||||
const businesses = await prisma.business.findMany({
|
||||
where: { managerId: manager.id, stripeAccountId: { not: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const selected = businesses.find((b) => b.id === requestedBusinessId) ?? businesses[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="p-2 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/zahlungen"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Zahlungen
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold">Auszahlungen</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Was Stripe auf dein Bankkonto überweist, deine Bankverbindung und eventuelle
|
||||
Zahlungsanfechtungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{businesses.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{businesses.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/zahlungen/auszahlungen?business=${b.id}`}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition-colors ${
|
||||
b.id === selected?.id
|
||||
? "bg-gray-900 text-white"
|
||||
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{b.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected ? (
|
||||
<StripePayoutsPanel businessId={selected.id} />
|
||||
) : (
|
||||
<div className="rounded-xl border border-gray-200 bg-white px-4 py-6 text-sm text-gray-600">
|
||||
Du hast noch kein Stripe-Konto verbunden.{" "}
|
||||
<Link href="/settings/payments" className="font-medium underline">
|
||||
Jetzt verbinden
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,14 +4,32 @@ import { getZahlungen } from "@/src/modules/zahlungen/actions/zahlungen-actions"
|
|||
import { getZahlungMetrics } from "@/src/modules/zahlungen/actions/zahlungen-metrics-actions";
|
||||
import ZahlungenView from "@/src/modules/zahlungen/ui/views/zahlungen-view";
|
||||
import ZahlungenLoading from "./loading";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { readTaxSettings } from "@/src/modules/settings/lib/tax-settings";
|
||||
|
||||
const ZahlungenPage = async () => {
|
||||
const manager = await requireManager();
|
||||
|
||||
const [zahlungenResult, metricsResult] = await Promise.all([
|
||||
const [zahlungenResult, metricsResult, businessTax] = await Promise.all([
|
||||
getZahlungen({}),
|
||||
getZahlungMetrics({}),
|
||||
// Für welche Studios noch keine Rechnungen erstellt werden können.
|
||||
prisma.business.findMany({
|
||||
where: { id: { in: manager.businesses.map((b) => b.id) } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
taxStatus: true,
|
||||
vatRate: true,
|
||||
taxNumber: true,
|
||||
ustId: true,
|
||||
taxExemptionNote: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const businessesMissingTax = businessTax
|
||||
.filter((b) => !readTaxSettings(b))
|
||||
.map((b) => ({ id: b.id, name: b.name }));
|
||||
|
||||
if (!zahlungenResult.success) {
|
||||
throw new Error(zahlungenResult.error);
|
||||
|
|
@ -27,6 +45,7 @@ const ZahlungenPage = async () => {
|
|||
initialCursor={zahlungenResult.nextCursor}
|
||||
initialMetrics={metricsResult.metrics}
|
||||
businesses={manager.businesses}
|
||||
businessesMissingTax={businessesMissingTax}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
86
src/app/api/stripe/account-session/route.ts
Normal file
86
src/app/api/stripe/account-session/route.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
|
||||
/**
|
||||
* Erstellt eine Account-Session für Stripes eingebettete Komponenten auf dem
|
||||
* Express-Account eines Studios - damit sieht das Studio Auszahlungen,
|
||||
* Zahlungsanfechtungen und offene Stripe-Hinweise direkt in Kumasy, ohne ins
|
||||
* Stripe-Dashboard zu wechseln.
|
||||
*
|
||||
* Das Client-Secret erlaubt dem Browser, Daten genau dieses Accounts bei
|
||||
* Stripe zu laden. Deshalb nur für den Manager, dem das Studio gehört.
|
||||
*
|
||||
* Bewusst ausgeschaltet sind Erstattungen: Die laufen über Kumasys
|
||||
* Stornierung, sonst wüsste die Buchung nichts von der Erstattung und Platz,
|
||||
* Warteliste und Rechnung wären falsch.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// requireManager() löst bei fehlender Session einen Redirect aus, der als
|
||||
// spezieller Error implementiert ist - darf nicht im try/catch landen.
|
||||
const manager = await requireManager();
|
||||
|
||||
const body = (await request.json().catch(() => null)) as { businessId?: unknown } | null;
|
||||
const businessId = body?.businessId;
|
||||
if (typeof businessId !== "string") {
|
||||
return NextResponse.json({ error: "businessId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { managerId: true, stripeAccountId: true },
|
||||
});
|
||||
|
||||
if (!business || business.managerId !== manager.id) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
if (!business.stripeAccountId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Für dieses Studio ist noch kein Stripe-Konto verbunden." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await stripe.accountSessions.create({
|
||||
account: business.stripeAccountId,
|
||||
components: {
|
||||
payouts: {
|
||||
enabled: true,
|
||||
features: {
|
||||
// Bankverbindung und Auszahlungsrhythmus direkt in Kumasy ändern.
|
||||
// Stripe verlangt dafür eine eigene Bestätigung des Studios.
|
||||
external_account_collection: true,
|
||||
edit_payout_schedule: true,
|
||||
instant_payouts: false,
|
||||
standard_payouts: false,
|
||||
},
|
||||
},
|
||||
disputes_list: {
|
||||
enabled: true,
|
||||
features: {
|
||||
// Seit den Direct Charges landen Anfechtungen beim Studio - es muss
|
||||
// sie beantworten können.
|
||||
dispute_management: true,
|
||||
refund_management: false,
|
||||
capture_payments: false,
|
||||
destination_on_behalf_of_charge_management: false,
|
||||
},
|
||||
},
|
||||
notification_banner: {
|
||||
enabled: true,
|
||||
features: { external_account_collection: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ clientSecret: session.client_secret });
|
||||
} catch (error) {
|
||||
console.error("Stripe-Account-Session konnte nicht erstellt werden:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Auszahlungen konnten gerade nicht geladen werden." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url";
|
||||
import { stripe, getPlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import {
|
||||
|
|
@ -9,6 +12,34 @@ import {
|
|||
calculateDiscountedPriceCents,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
/**
|
||||
* Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
|
||||
* Branding ab (z.B. ein Logo, das Stripe nicht laden kann), wird ohne Branding
|
||||
* erneut versucht - ein Logo darf nie eine Buchung verhindern.
|
||||
*/
|
||||
async function createBrandedCheckoutSession(
|
||||
params: Stripe.Checkout.SessionCreateParams,
|
||||
branding: Stripe.Checkout.SessionCreateParams.BrandingSettings,
|
||||
stripeAccountId: string
|
||||
) {
|
||||
// Direct Charge: die Session gehört dem Studio-Account.
|
||||
const options = { stripeAccount: stripeAccountId };
|
||||
try {
|
||||
return await stripe.checkout.sessions.create(
|
||||
{ ...params, branding_settings: branding },
|
||||
options
|
||||
);
|
||||
} catch (error) {
|
||||
const param = (error as { param?: string }).param;
|
||||
if (!param?.startsWith("branding_settings")) throw error;
|
||||
console.warn(
|
||||
`Checkout-Branding von Stripe abgelehnt (${param}), Session ohne Branding:`,
|
||||
error
|
||||
);
|
||||
return stripe.checkout.sessions.create(params, options);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||
// its own Map, so a determined attacker can bypass it. The main purpose is to
|
||||
|
|
@ -40,7 +71,11 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||
|
||||
/**
|
||||
* Erstellt eine Stripe Checkout Session für eine Kurs-Buchung.
|
||||
* Geld geht direkt an das Business-Connect-Account (minus Plattform-Fee).
|
||||
*
|
||||
* Die Session entsteht als Direct Charge auf dem Express-Account des Studios:
|
||||
* Das Geld landet direkt dort, Stripe zieht seine Gebühr beim Studio ab, und
|
||||
* die Servicegebühr geht als application_fee an Kumasy. Gebucht wird hier noch
|
||||
* nichts - die Buchung legt erst der Webhook an, wenn Stripe die Zahlung meldet.
|
||||
*
|
||||
* Auth-Flow → Body: { courseId: string }
|
||||
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
|
||||
|
|
@ -124,6 +159,7 @@ export async function POST(request: NextRequest) {
|
|||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
branding: true,
|
||||
stripeAccountId: true,
|
||||
stripeAccountStatus: true,
|
||||
},
|
||||
|
|
@ -244,62 +280,99 @@ export async function POST(request: NextRequest) {
|
|||
reservationToken = reservation.reservationToken;
|
||||
}
|
||||
|
||||
// Platform-Fee auf den tatsächlich zu zahlenden (rabattierten) Betrag,
|
||||
// nicht den Originalpreis — das entspricht dem, was via
|
||||
// application_fee_amount wirklich von diesem Payment einbehalten wird.
|
||||
const platformFeeCents = calculatePlatformFeeCents(priceCents / 100);
|
||||
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
|
||||
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
|
||||
// ist damit priceCents + platformFeeCents; application_fee_amount führt
|
||||
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
|
||||
// nie die Gebühr.
|
||||
const platformFeeCents = getPlatformFeeCents();
|
||||
|
||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||
|
||||
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
|
||||
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
|
||||
// Zahlung sichtbar ist.
|
||||
const metadata = {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
|
||||
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
|
||||
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
|
||||
// "obendrauf" verbucht werden.
|
||||
platformFeeModel: "on_top",
|
||||
};
|
||||
|
||||
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
|
||||
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
|
||||
// die Kumasy-Farben.
|
||||
const studioBranding = toEmailBranding(course.business);
|
||||
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
|
||||
display_name: course.business.name,
|
||||
background_color: studioBranding.primaryColor,
|
||||
button_color: studioBranding.accentColor,
|
||||
border_style: "rounded",
|
||||
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
|
||||
? { logo: { type: "url", url: studioBranding.logoUrl } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: course.name,
|
||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
||||
session = await createBrandedCheckoutSession(
|
||||
{
|
||||
mode: "payment",
|
||||
// Rechnungsadresse ist Pflicht - für Gäste wie für Kunden mit Konto.
|
||||
// Sie steht als Momentaufnahme an der Buchung und auf der Rechnung.
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: course.name,
|
||||
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
|
||||
},
|
||||
unit_amount: priceCents,
|
||||
},
|
||||
unit_amount: priceCents,
|
||||
quantity: 1,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
application_fee_amount: platformFeeCents,
|
||||
transfer_data: {
|
||||
destination: course.business.stripeAccountId,
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
// Bei STRIPE_PLATFORM_FEE_CENTS=0 entfällt die Position ganz, statt
|
||||
// dem Kunden eine 0-€-Zeile im Checkout zu zeigen.
|
||||
...(platformFeeCents > 0
|
||||
? [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur" as const,
|
||||
product_data: {
|
||||
name: "Servicegebühr",
|
||||
description: "Gebühr für die Online-Buchung",
|
||||
},
|
||||
unit_amount: platformFeeCents,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
payment_intent_data: {
|
||||
...(platformFeeCents > 0 ? { application_fee_amount: platformFeeCents } : {}),
|
||||
metadata,
|
||||
},
|
||||
metadata,
|
||||
customer_email: customerEmail,
|
||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||
},
|
||||
metadata: {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
},
|
||||
customer_email: customerEmail,
|
||||
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
|
||||
});
|
||||
brandingSettings,
|
||||
course.business.stripeAccountId
|
||||
);
|
||||
} catch (stripeError) {
|
||||
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
||||
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { stripe, getWebhookSecret } from "@/src/lib/stripe";
|
||||
import { stripe, getWebhookSecrets, onAccount } from "@/src/lib/stripe";
|
||||
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||||
import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee";
|
||||
import { billingAddressFromCheckout, type BillingAddress } from "@/src/lib/billing-address";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { sendMail } from "@/src/lib/mail/resend";
|
||||
|
|
@ -38,10 +40,21 @@ async function findUserByEmailInsensitive(email: string) {
|
|||
* WICHTIG: Erst hier wird die CourseBooking endgültig in der DB als bezahlt markiert.
|
||||
* Niemals den Booking-Status auf der Success-Page-Seite setzen (kann umgangen werden).
|
||||
*
|
||||
* Zahlungen laufen als Direct Charges auf den Studio-Accounts. Ihre Events
|
||||
* kommen deshalb als Connect-Events mit `event.account` = Studio-Account; der
|
||||
* wird an jeden Stripe-Aufruf und an die Buchung weitergereicht. Events ohne
|
||||
* `account` stammen aus Checkouts von vor der Umstellung (Destination Charges
|
||||
* auf dem Plattformkonto) und laufen durch dieselben Handler.
|
||||
*
|
||||
* Die folgenden Events werden verarbeitet:
|
||||
* - checkout.session.completed → Booking als "paid" markieren (oder erstellen)
|
||||
* - checkout.session.expired → Pending-Booking aufräumen
|
||||
* - account.updated → Stripe Connect Account-Status synchronisieren
|
||||
* - checkout.session.completed → Buchung anlegen, sobald bezahlt. Bei
|
||||
* verzögerten Zahlungsarten (SEPA) ist die Session hier noch "unpaid".
|
||||
* - checkout.session.async_payment_succeeded → die verzögerte Zahlung ist
|
||||
* angekommen, Buchung anlegen wie oben.
|
||||
* - checkout.session.expired / async_payment_failed → nie bezahlt: Rabattcode-
|
||||
* Reservierung freigeben, Pending-Buchung aufräumen.
|
||||
* - charge.updated → Stripe-Gebühr nachtragen, sobald Stripe sie kennt.
|
||||
* - account.updated → Stripe Connect Account-Status synchronisieren.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
|
|
@ -53,20 +66,34 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, getWebhookSecret());
|
||||
event = constructVerifiedEvent(rawBody, signature);
|
||||
} catch (err) {
|
||||
console.error("Webhook-Signatur ungültig:", err);
|
||||
return NextResponse.json({ error: "Ungültige Signatur" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Studio-Account, auf dem die Zahlung liegt. null bei Plattform-Events und
|
||||
// bei Altbuchungen (Destination Charges von vor der Umstellung).
|
||||
const stripeAccountId = event.account ?? null;
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
await handleCheckoutCompleted(event.data.object);
|
||||
case "checkout.session.completed":
|
||||
case "checkout.session.async_payment_succeeded": {
|
||||
await handleCheckoutCompleted(event.data.object, stripeAccountId);
|
||||
break;
|
||||
}
|
||||
case "checkout.session.expired": {
|
||||
await handleCheckoutExpired(event.data.object);
|
||||
case "checkout.session.expired":
|
||||
case "checkout.session.async_payment_failed": {
|
||||
await handleCheckoutUnpaid(event.data.object);
|
||||
break;
|
||||
}
|
||||
case "charge.updated": {
|
||||
// Nur Direct Charges: bei Altbuchungen trug die Plattform die Gebühr,
|
||||
// für das Studio gibt es dort nichts nachzutragen.
|
||||
if (stripeAccountId) {
|
||||
await recordStripeFeeForCharge(event.data.object, stripeAccountId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "account.updated": {
|
||||
|
|
@ -86,11 +113,74 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine erfolgreiche Checkout-Session.
|
||||
* Prüft die Signatur gegen alle konfigurierten Secrets - Plattform- und
|
||||
* Connect-Endpoint zeigen auf diese Route (siehe getWebhookSecrets). Wirft,
|
||||
* wenn keines passt.
|
||||
*/
|
||||
function constructVerifiedEvent(rawBody: string, signature: string): Stripe.Event {
|
||||
let lastError: unknown;
|
||||
for (const secret of getWebhookSecrets()) {
|
||||
try {
|
||||
return stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was der Webhook über die Zahlung einer Session weiß. Landet unverändert an
|
||||
* jeder Buchungszeile, die zu dieser Session entsteht - bezahlt oder wegen
|
||||
* Überbuchung abgelehnt.
|
||||
*/
|
||||
type SessionPayment = {
|
||||
paymentIntentId: string | null;
|
||||
/** Studio-Account der Direct Charge. null = Altbuchung auf der Plattform. */
|
||||
stripeAccountId: string | null;
|
||||
/** Was der Kunde insgesamt gezahlt hat, inklusive Servicegebühr. */
|
||||
amountPaidCents: number;
|
||||
/** application_fee laut Stripe - die Servicegebühr, die an Kumasy ging. */
|
||||
platformFeeCents: number;
|
||||
/** true = Gebühr kam obendrauf. false = Altbuchung mit prozentualer Gebühr. */
|
||||
platformFeeOnTop: boolean;
|
||||
/** Im Checkout angegebene Rechnungsadresse, Momentaufnahme für die Rechnung. */
|
||||
billingAddress: BillingAddress | null;
|
||||
};
|
||||
|
||||
function paymentColumns(sessionId: string, payment: SessionPayment) {
|
||||
return {
|
||||
stripeSessionId: sessionId,
|
||||
stripePaymentIntentId: payment.paymentIntentId,
|
||||
stripeAccountId: payment.stripeAccountId,
|
||||
amountPaid: payment.amountPaidCents,
|
||||
platformFee: payment.platformFeeCents,
|
||||
platformFeeOnTop: payment.platformFeeOnTop,
|
||||
...(payment.billingAddress ? { billingAddress: payment.billingAddress } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstattet eine Zahlung, die die Plattform abgelehnt hat (Überbuchung,
|
||||
* Doppelzahlung, Team-Account als Gast). Voller Betrag an den Kunden, und
|
||||
* Kumasy gibt seine Servicegebühr an das Studio zurück - ein Fehler auf
|
||||
* unserer Seite soll das Studio nicht die 0,50 € kosten. Die Stripe-Gebühr
|
||||
* erstattet Stripe nie; die bleibt in diesem seltenen Fall beim Studio.
|
||||
*/
|
||||
function refundRejectedPayment(paymentIntentId: string, stripeAccountId: string | null) {
|
||||
return refundStripePayment({ paymentIntentId, stripeAccountId, refundApplicationFee: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine bezahlte Checkout-Session - sofort bezahlt
|
||||
* (checkout.session.completed) oder verzögert (async_payment_succeeded).
|
||||
* Erstellt die CourseBooking (falls nicht vorhanden) und setzt sie auf "paid".
|
||||
* Unterstützt Auth-User und Gäste (isGuest === "true" in Metadata).
|
||||
*/
|
||||
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
||||
async function handleCheckoutCompleted(
|
||||
session: Stripe.Checkout.Session,
|
||||
stripeAccountId: string | null
|
||||
) {
|
||||
const courseId = session.metadata?.courseId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
|
||||
|
|
@ -100,17 +190,29 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
}
|
||||
|
||||
if (session.payment_status !== "paid") {
|
||||
console.warn(
|
||||
`Session ${session.id} ist nicht 'paid', aktueller Status: ${session.payment_status}`
|
||||
// Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout
|
||||
// abgeschlossen, das Geld ist aber noch unterwegs. Gebucht wird erst bei
|
||||
// checkout.session.async_payment_succeeded. Die Rabattcode-Reservierung
|
||||
// bleibt bis dahin bestehen.
|
||||
console.info(
|
||||
`Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
|
||||
`warte auf checkout.session.async_payment_succeeded.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const amountPaidCents = session.amount_total ?? 0;
|
||||
const paymentIntentId = (session.payment_intent as string | null) ?? null;
|
||||
const platformFeeCents = paymentIntentId
|
||||
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
|
||||
: 0;
|
||||
const payment: SessionPayment = {
|
||||
paymentIntentId,
|
||||
stripeAccountId,
|
||||
amountPaidCents,
|
||||
platformFeeCents: paymentIntentId
|
||||
? await getApplicationFeeFromPaymentIntent(paymentIntentId, stripeAccountId)
|
||||
: 0,
|
||||
platformFeeOnTop: session.metadata?.platformFeeModel === "on_top",
|
||||
billingAddress: billingAddressFromCheckout(session.customer_details),
|
||||
};
|
||||
// discountAmountCents ist ein Snapshot vom Checkout-Start (in der Session-
|
||||
// Metadata eingefroren) — bewusst NICHT aus dem aktuellen DiscountCode neu
|
||||
// berechnet, damit eine spätere Code-Änderung die Rechnung dieser Buchung
|
||||
|
|
@ -153,7 +255,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
`Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
|
||||
);
|
||||
if (paymentIntentId) {
|
||||
const refundResult = await refundStripePayment(paymentIntentId);
|
||||
const refundResult = await refundRejectedPayment(paymentIntentId, stripeAccountId);
|
||||
if (!refundResult.success) {
|
||||
console.error(
|
||||
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
|
||||
|
|
@ -176,9 +278,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
courseId,
|
||||
userId: registeredUser.id,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
|
|
@ -189,9 +289,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
guestEmail,
|
||||
guestName: guestName || undefined,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
});
|
||||
} else {
|
||||
const userId = session.metadata?.userId;
|
||||
|
|
@ -211,7 +309,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
`Team-Account. Buchung wird abgelehnt, Zahlung wird erstattet (Session ${session.id}).`
|
||||
);
|
||||
if (paymentIntentId) {
|
||||
const refundResult = await refundStripePayment(paymentIntentId);
|
||||
const refundResult = await refundRejectedPayment(paymentIntentId, stripeAccountId);
|
||||
if (!refundResult.success) {
|
||||
console.error(
|
||||
`Refund fehlgeschlagen für abgelehnte Team-Account-Gastbuchung ` +
|
||||
|
|
@ -229,9 +327,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
courseId,
|
||||
userId: registeredUser.id,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
|
|
@ -242,9 +338,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
guestEmail: fallbackEmail,
|
||||
guestName: session.metadata?.guestName || undefined,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
});
|
||||
} else {
|
||||
console.error(
|
||||
|
|
@ -274,9 +368,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
courseId,
|
||||
userId,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
discountCodeId,
|
||||
discountAmountCents,
|
||||
discountReservationToken,
|
||||
|
|
@ -319,7 +411,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
`Kurs ${courseId} war voll als Zahlung ankam. ` +
|
||||
`Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
|
||||
);
|
||||
const refundResult = await refundStripePayment(paymentIntentId);
|
||||
const refundResult = await refundRejectedPayment(paymentIntentId, stripeAccountId);
|
||||
refundSucceeded = refundResult.success;
|
||||
if (refundResult.success && outcome.bookingId) {
|
||||
await prisma.courseBooking.update({
|
||||
|
|
@ -351,7 +443,7 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
`Doppelzahlung erkannt für Kurs ${courseId} (Buchung ${outcome.bookingId} existiert bereits ` +
|
||||
`mit einer anderen Session). Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
|
||||
);
|
||||
const refundResult = await refundStripePayment(paymentIntentId);
|
||||
const refundResult = await refundRejectedPayment(paymentIntentId, stripeAccountId);
|
||||
duplicateRefundSucceeded = refundResult.success;
|
||||
if (!refundResult.success) {
|
||||
console.error(
|
||||
|
|
@ -361,6 +453,19 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|||
}
|
||||
}
|
||||
|
||||
// Stripe-Gebühr für den Zahlungen-Tab festhalten. Nur bei Direct Charges -
|
||||
// bei Altbuchungen trug die Plattform die Gebühr. Steht sie noch nicht fest,
|
||||
// trägt charge.updated sie nach. Ein Fehler hier darf die Buchung nicht
|
||||
// kippen: das Geld ist geflossen, die Buchung steht.
|
||||
if (outcome.result === "booked" && paymentIntentId && stripeAccountId) {
|
||||
await recordStripeFee(paymentIntentId, stripeAccountId).catch((err) =>
|
||||
console.error(
|
||||
`Stripe-Gebühr für PaymentIntent ${paymentIntentId} konnte nicht gelesen werden:`,
|
||||
err
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// E-Mail-Versand – darf den Webhook NICHT zum Crashen bringen.
|
||||
const recipientEmail = session.customer_email ?? session.customer_details?.email ?? null;
|
||||
if (recipientEmail) {
|
||||
|
|
@ -456,9 +561,7 @@ async function runCapacityTransactionUser(params: {
|
|||
courseId: string;
|
||||
userId: string;
|
||||
session: Stripe.Checkout.Session;
|
||||
paymentIntentId: string | null;
|
||||
amountPaidCents: number;
|
||||
platformFeeCents: number;
|
||||
payment: SessionPayment;
|
||||
discountCodeId?: string | null;
|
||||
discountAmountCents?: number;
|
||||
discountReservationToken?: string | null;
|
||||
|
|
@ -468,9 +571,7 @@ async function runCapacityTransactionUser(params: {
|
|||
courseId,
|
||||
userId,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
payment,
|
||||
discountCodeId = null,
|
||||
discountAmountCents = 0,
|
||||
discountReservationToken = null,
|
||||
|
|
@ -550,19 +651,13 @@ async function runCapacityTransactionUser(params: {
|
|||
where: { courseId_userId: { courseId, userId } },
|
||||
update: {
|
||||
paymentStatus: "failed",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
},
|
||||
create: {
|
||||
courseId,
|
||||
userId,
|
||||
paymentStatus: "failed",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
},
|
||||
});
|
||||
return { result: "capacity_exceeded" as const, bookingId: booking.id };
|
||||
|
|
@ -572,10 +667,7 @@ async function runCapacityTransactionUser(params: {
|
|||
where: { courseId_userId: { courseId, userId } },
|
||||
update: {
|
||||
paymentStatus: "paid",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
discountCodeId,
|
||||
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||
},
|
||||
|
|
@ -583,10 +675,7 @@ async function runCapacityTransactionUser(params: {
|
|||
courseId,
|
||||
userId,
|
||||
paymentStatus: "paid",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
discountCodeId,
|
||||
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||
},
|
||||
|
|
@ -624,19 +713,9 @@ async function runCapacityTransactionGuest(params: {
|
|||
guestEmail: string;
|
||||
guestName?: string;
|
||||
session: Stripe.Checkout.Session;
|
||||
paymentIntentId: string | null;
|
||||
amountPaidCents: number;
|
||||
platformFeeCents: number;
|
||||
payment: SessionPayment;
|
||||
}): Promise<CapacityOutcome> {
|
||||
const {
|
||||
courseId,
|
||||
guestEmail,
|
||||
guestName,
|
||||
session,
|
||||
paymentIntentId,
|
||||
amountPaidCents,
|
||||
platformFeeCents,
|
||||
} = params;
|
||||
const { courseId, guestEmail, guestName, session, payment } = params;
|
||||
|
||||
return prisma.$transaction(
|
||||
async (tx) => {
|
||||
|
|
@ -686,10 +765,7 @@ async function runCapacityTransactionGuest(params: {
|
|||
courseId,
|
||||
guestLeadId: guestLead.id,
|
||||
paymentStatus: "failed",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
},
|
||||
});
|
||||
return { result: "capacity_exceeded" as const, bookingId: booking.id };
|
||||
|
|
@ -705,10 +781,7 @@ async function runCapacityTransactionGuest(params: {
|
|||
courseId,
|
||||
guestLeadId: guestLead.id,
|
||||
paymentStatus: "paid",
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...paymentColumns(session.id, payment),
|
||||
},
|
||||
});
|
||||
return { result: "booked" as const, bookingId: booking.id };
|
||||
|
|
@ -859,9 +932,16 @@ async function sendBookingRefund(
|
|||
}
|
||||
}
|
||||
|
||||
async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Promise<number> {
|
||||
async function getApplicationFeeFromPaymentIntent(
|
||||
paymentIntentId: string,
|
||||
stripeAccountId: string | null
|
||||
): Promise<number> {
|
||||
try {
|
||||
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
const intent = await stripe.paymentIntents.retrieve(
|
||||
paymentIntentId,
|
||||
undefined,
|
||||
onAccount(stripeAccountId)
|
||||
);
|
||||
return intent.application_fee_amount ?? 0;
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Abrufen des PaymentIntent:", err);
|
||||
|
|
@ -870,10 +950,11 @@ async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Prom
|
|||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine abgelaufene Checkout-Session.
|
||||
* Setzt eventuell vorhandene Pending-Buchung auf "failed".
|
||||
* Verarbeitet eine Checkout-Session, die nie bezahlt wurde: abgelaufen, oder
|
||||
* die verzögerte Zahlung (SEPA) ist gescheitert. Gibt die Rabattcode-
|
||||
* Reservierung frei und setzt eine eventuelle Pending-Buchung auf "failed".
|
||||
*/
|
||||
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||
async function handleCheckoutUnpaid(session: Stripe.Checkout.Session) {
|
||||
const courseId = session.metadata?.courseId;
|
||||
const isGuest = session.metadata?.isGuest === "true";
|
||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||
|
|
|
|||
|
|
@ -17,14 +17,24 @@ export async function GET(
|
|||
const { bookingId } = await params;
|
||||
|
||||
try {
|
||||
const invoiceData = await assembleInvoiceData(bookingId, manager);
|
||||
const result = await assembleInvoiceData(bookingId, manager);
|
||||
|
||||
if (!invoiceData) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rechnung nur für bezahlte Zahlungen verfügbar." },
|
||||
{ status: 404 }
|
||||
);
|
||||
if (!result.ok) {
|
||||
return result.reason === "missing_tax_settings"
|
||||
? NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Bitte hinterlege zuerst deine Steuerangaben unter Einstellungen → Business. " +
|
||||
"Ohne sie kann keine korrekte Rechnung erstellt werden.",
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
: NextResponse.json(
|
||||
{ error: "Rechnung nur für bezahlte Zahlungen verfügbar." },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
const invoiceData = result.data;
|
||||
|
||||
// Ein fehlgeschlagener Logo-Nachlade-Versuch (z.B. unerreichbare/gelöschte
|
||||
// Storage-URL) darf den Download nicht blockieren - zweiter Versuch ohne
|
||||
|
|
|
|||
|
|
@ -55,7 +55,18 @@ export async function GET(request: Request) {
|
|||
}
|
||||
|
||||
// Stammdaten aller Buchungen in einer Query statt einer Query pro ID.
|
||||
const invoices = await assembleInvoiceDataBatch(ids, manager);
|
||||
const { invoices, businessesMissingTax } = await assembleInvoiceDataBatch(ids, manager);
|
||||
|
||||
if (invoices.length === 0 && businessesMissingTax.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Bitte hinterlege zuerst deine Steuerangaben unter Einstellungen → Business. " +
|
||||
"Ohne sie kann keine korrekte Rechnung erstellt werden.",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const archive = new archiver.ZipArchive({ zlib: { level: 9 } });
|
||||
const passthrough = new PassThrough();
|
||||
|
|
@ -102,6 +113,14 @@ export async function GET(request: Request) {
|
|||
archive.append(buffer, { name: fileName });
|
||||
}
|
||||
|
||||
if (businessesMissingTax.length > 0) {
|
||||
archive.append(
|
||||
`Für folgende Studios fehlen die Steuerangaben, ihre Rechnungen sind nicht enthalten: ` +
|
||||
`${businessesMissingTax.join(", ")}. Bitte unter Einstellungen → Business nachtragen.`,
|
||||
{ name: "FEHLENDE-STEUERANGABEN.txt" }
|
||||
);
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
archive.append(
|
||||
`Es gab mehr als ${ids.length} Zahlungen für den gewählten Filter. Bitte den Zeitraum eingrenzen, um alle Rechnungen zu erhalten.`,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import {
|
|||
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
|
||||
import { businessBrandingSchema } from "@/src/modules/settings/schemas/business-branding-schema";
|
||||
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
|
||||
import { getPlatformFeeCents } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { cancellationPolicySchema } from "@/src/modules/settings/schemas/cancellation-policy-schema";
|
||||
import { DEFAULT_POLICY } from "@/src/modules/courses/actions/cancellation-policy";
|
||||
import { describeCancellationPolicy } from "@/src/modules/courses/lib/cancellation-summary";
|
||||
|
||||
interface BusinessPageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -40,15 +45,27 @@ export default async function BusinessPage({ params }: BusinessPageProps) {
|
|||
accentColor: branding.accentColor,
|
||||
};
|
||||
|
||||
// Einen Tag Puffer nach hinten: Kurse sind auf Mitternacht Berliner Zeit
|
||||
// datiert, und "heute 0:00" in der Zeitzone des Servers (z.B. UTC) würde die
|
||||
// heutigen Kurse abschneiden. Welche Tage tatsächlich "kommend" sind,
|
||||
// entscheidet die Ansicht in Berliner Zeit.
|
||||
const from = new Date();
|
||||
from.setHours(0, 0, 0, 0);
|
||||
from.setDate(from.getDate() - 1);
|
||||
const to = new Date(from);
|
||||
to.setDate(to.getDate() + 30);
|
||||
to.setDate(to.getDate() + 31);
|
||||
|
||||
const coursesResult = await getPublishedCoursesForBusiness(businessResult.business.id, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
const [coursesResult, policyRow] = await Promise.all([
|
||||
getPublishedCoursesForBusiness(businessResult.business.id, { from, to }),
|
||||
prisma.business.findUnique({
|
||||
where: { id: businessResult.business.id },
|
||||
select: { cancellationPolicy: true },
|
||||
}),
|
||||
]);
|
||||
// Kunden sollen vor dem Buchen wissen, ob und bis wann sie absagen können.
|
||||
const parsedPolicy = cancellationPolicySchema.safeParse(policyRow?.cancellationPolicy);
|
||||
const cancellationSummary = describeCancellationPolicy(
|
||||
parsedPolicy.success ? parsedPolicy.data : DEFAULT_POLICY
|
||||
);
|
||||
|
||||
return (
|
||||
<BusinessPublicView
|
||||
|
|
@ -57,6 +74,10 @@ export default async function BusinessPage({ params }: BusinessPageProps) {
|
|||
(coursesResult.courses ?? []) as Parameters<typeof BusinessPublicView>[0]["initialCourses"]
|
||||
}
|
||||
initialWindow={{ from, to }}
|
||||
// Dieselbe Quelle wie im Checkout - angezeigter und berechneter Preis
|
||||
// können so nicht auseinanderlaufen.
|
||||
serviceFeeCents={getPlatformFeeCents()}
|
||||
cancellationSummary={cancellationSummary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,11 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{businessSlug && (
|
||||
{/* Kein Link auf "/" mehr: die Startseite gibt es nicht, sie leitet zum
|
||||
Login weiter - für einen Gast, der gerade die Zahlung abgebrochen
|
||||
hat, das falsche Ziel. Zurück geht es zum Studio. */}
|
||||
{businessSlug && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href={`/business/${businessSlug}`}>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
|
|
@ -59,13 +62,8 @@ export default async function CourseBookingCancelPage({ params }: CancelPageProp
|
|||
Erneut versuchen
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/">
|
||||
<Button variant="outline" className="w-full sm:w-auto">
|
||||
Zur Startseite
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
2531
src/app/page.tsx
2531
src/app/page.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { X, Share, Plus, Download } from "lucide-react";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import {
|
||||
|
|
@ -14,6 +15,11 @@ interface BeforeInstallPromptEvent extends Event {
|
|||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
// Seiten, die die Kunden eines Studios sehen. Die Kumasy-App ist ein Werkzeug
|
||||
// für Studios - Kunden, die nur einen Kurs buchen, soll sie nicht angeboten
|
||||
// werden.
|
||||
const CUSTOMER_FACING_PREFIXES = ["/business/", "/courses/book/"];
|
||||
|
||||
function isIos(): boolean {
|
||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
|
||||
}
|
||||
|
|
@ -29,6 +35,8 @@ function subscribeNever() {
|
|||
}
|
||||
|
||||
export function InstallPrompt() {
|
||||
const pathname = usePathname();
|
||||
const isCustomerFacing = CUSTOMER_FACING_PREFIXES.some((prefix) => pathname?.startsWith(prefix));
|
||||
// useSyncExternalStore statt Lazy-useState/Effect: getServerSnapshot liefert
|
||||
// serverseitig UND im allerersten Client-Pass vor der Hydration "false",
|
||||
// React ersetzt es danach durch den echten Browser-Wert - ohne
|
||||
|
|
@ -72,6 +80,7 @@ export function InstallPrompt() {
|
|||
if (outcome === "accepted") dismiss();
|
||||
};
|
||||
|
||||
if (isCustomerFacing) return null;
|
||||
if (!eligible || (!deferredPrompt && !showIosHint)) return null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
56
src/lib/billing-address.ts
Normal file
56
src/lib/billing-address.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Rechnungsadresse einer Buchung, wie der Kunde sie im Stripe-Checkout
|
||||
* angegeben hat. Wird pro Buchung als JSON gespeichert (CourseBooking.
|
||||
* billingAddress), damit eine einmal erstellte Rechnung gleich bleibt.
|
||||
*/
|
||||
export type BillingAddress = {
|
||||
name: string | null;
|
||||
line1: string;
|
||||
line2: string | null;
|
||||
postalCode: string;
|
||||
city: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
/** Aus den Kundendaten einer Checkout-Session; null, wenn nichts Vollständiges da ist. */
|
||||
export function billingAddressFromCheckout(
|
||||
details: Stripe.Checkout.Session["customer_details"] | undefined
|
||||
): BillingAddress | null {
|
||||
const address = details?.address;
|
||||
if (!address?.line1 || !address.postal_code || !address.city || !address.country) return null;
|
||||
return {
|
||||
name: details?.name?.trim() || null,
|
||||
line1: address.line1,
|
||||
line2: address.line2 || null,
|
||||
postalCode: address.postal_code,
|
||||
city: address.city,
|
||||
country: address.country,
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest den gespeicherten JSON-Wert; alles Unvollständige gilt als nicht vorhanden. */
|
||||
export function readBillingAddress(value: unknown): BillingAddress | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const v = value as Record<string, unknown>;
|
||||
const str = (key: string) => (typeof v[key] === "string" && v[key] ? (v[key] as string) : null);
|
||||
const line1 = str("line1");
|
||||
const postalCode = str("postalCode");
|
||||
const city = str("city");
|
||||
const country = str("country");
|
||||
if (!line1 || !postalCode || !city || !country) return null;
|
||||
return { name: str("name"), line1, line2: str("line2"), postalCode, city, country };
|
||||
}
|
||||
|
||||
/** Zeilen für die Rechnung; das Land nur, wenn es nicht Deutschland ist. */
|
||||
export function formatBillingAddressLines(address: BillingAddress): string[] {
|
||||
const lines = [address.line1];
|
||||
if (address.line2) lines.push(address.line2);
|
||||
lines.push(`${address.postalCode} ${address.city}`);
|
||||
if (address.country !== "DE") {
|
||||
const country = new Intl.DisplayNames(["de"], { type: "region" }).of(address.country);
|
||||
lines.push(country ?? address.country);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
73
src/lib/booking-amounts.ts
Normal file
73
src/lib/booking-amounts.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Kursanteil und Studio-Umsatz einer Buchung.
|
||||
*
|
||||
* Seit der fixen Servicegebühr zahlt der Kunde Kurspreis + Gebühr. `amountPaid`
|
||||
* ist der Brutto-Betrag der Stripe-Charge (session.amount_total) und enthält
|
||||
* die Gebühr - er ist damit NICHT mehr das, was dem Studio zusteht.
|
||||
*
|
||||
* Buchungen aus der Zeit der prozentualen Fee funktionierten umgekehrt: dort
|
||||
* wurde die Fee aus dem Kurspreis einbehalten, der Kunde zahlte nur den Kurs,
|
||||
* und `amountPaid` IST bereits der Kursanteil. `platformFeeOnTop` unterscheidet
|
||||
* beide Welten, damit Altbuchungen nicht rückwirkend zu niedrig ausgewiesen
|
||||
* werden - das würde sonst bereits ausgestellte Rechnungen verändern.
|
||||
*/
|
||||
export type BookingAmounts = {
|
||||
amountPaid: number | null;
|
||||
platformFee: number | null;
|
||||
platformFeeOnTop: boolean;
|
||||
};
|
||||
|
||||
/** Zusätzlich die Stripe-Gebühr, die das Studio getragen hat. */
|
||||
export type BookingPayout = BookingAmounts & { stripeFee: number | null };
|
||||
|
||||
/**
|
||||
* Felder, die jede Umsatz-Abfrage laden muss. Gemeinsam definiert, weil der
|
||||
* Umsatz an mehreren Stellen gezeigt wird (Dashboard, Zahlungen-Tab) - eine
|
||||
* reine _sum-Aggregation über amountPaid würde die Servicegebühr mitzählen,
|
||||
* die der Kunde an Kumasy gezahlt hat. Genau dieser Fehler stand einmal im
|
||||
* Zahlungen-Tab, während das Dashboard richtig rechnete.
|
||||
*/
|
||||
export const bookingRevenueSelect = {
|
||||
amountPaid: true,
|
||||
platformFee: true,
|
||||
platformFeeOnTop: true,
|
||||
refundedAmount: true,
|
||||
} as const;
|
||||
|
||||
/** Betrag, der dem Studio zusteht - ohne Servicegebühr, vor Erstattungen. */
|
||||
export function courseAmountCents(booking: BookingAmounts): number {
|
||||
const paid = booking.amountPaid ?? 0;
|
||||
return booking.platformFeeOnTop ? paid - (booking.platformFee ?? 0) : paid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Betrag, der beim Studio tatsächlich ankommt, vor Erstattungen:
|
||||
* gezahlt − Plattformgebühr − Stripe-Gebühr.
|
||||
*
|
||||
* Die Formel gilt für jedes Modell, das in der DB vorkommt, weil die
|
||||
* Plattformgebühr in allen vom Zahlungsbetrag des Studios abgeht:
|
||||
* - Altbuchung mit prozentualer Gebühr: die 5 % gingen vom Kurspreis ab, die
|
||||
* Stripe-Gebühr trug die Plattform (stripeFee null).
|
||||
* - Direct Charge, Gebühr obendrauf: der Kunde zahlt die 0,50 € zusätzlich,
|
||||
* sie gehen an Kumasy; die Stripe-Gebühr zieht Stripe beim Studio ab.
|
||||
* Solange Stripe sie nicht abgerechnet hat, ist stripeFee null und die
|
||||
* Auszahlung vorläufig um diese Gebühr zu hoch.
|
||||
*
|
||||
* Bewusst getrennt von courseAmountCents: der Umsatz des Studios ist der
|
||||
* Kurspreis, die Gebühren sind seine Kosten. Kennzahlen nutzen den Umsatz,
|
||||
* die Zahlungsübersicht die Auszahlung.
|
||||
*/
|
||||
export function payoutAmountCents(booking: BookingPayout): number {
|
||||
return (booking.amountPaid ?? 0) - (booking.platformFee ?? 0) - (booking.stripeFee ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Studio-Umsatz über mehrere Buchungen: Kursanteil abzüglich bereits
|
||||
* erstatteter Beträge. Für "paid"-Zeilen ist refundedAmount null, die Formel
|
||||
* gilt also unverändert auch dort.
|
||||
*/
|
||||
export function sumCourseRevenueCents(
|
||||
bookings: (BookingAmounts & { refundedAmount?: number | null })[]
|
||||
): number {
|
||||
return bookings.reduce((sum, b) => sum + courseAmountCents(b) - (b.refundedAmount ?? 0), 0);
|
||||
}
|
||||
81
src/lib/stripe-fee.ts
Normal file
81
src/lib/stripe-fee.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type Stripe from "stripe";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
/**
|
||||
* Liest die Stripe-Gebühr einer Zahlung aus und speichert sie an der Buchung,
|
||||
* damit der Zahlungen-Tab zeigen kann, was beim Studio ankommt.
|
||||
*
|
||||
* Bei Direct Charges zieht Stripe die Gebühr selbst beim Studio ab - hier wird
|
||||
* kein Geld bewegt, nur protokolliert. Zwei Eigenheiten, die beide empirisch
|
||||
* gegen den Test-Account bestätigt sind:
|
||||
*
|
||||
* - `balance_transaction.fee` enthält BEIDE Abzüge: Stripes Gebühr und unsere
|
||||
* application_fee. Die Stripe-Gebühr ist deshalb alles in `fee_details`
|
||||
* außer der application_fee.
|
||||
* - Die Gebühr steht bei `charge.succeeded` oft noch nicht fest
|
||||
* (`balance_transaction` ist null) und wird erst mit `charge.updated`
|
||||
* nachgeliefert. Daher zwei Einstiege, siehe unten. Welches Event zuerst
|
||||
* ankommt, ist egal: gespeichert wird nur, solange stripeFee noch null ist.
|
||||
*/
|
||||
|
||||
/** Was Stripe abgezogen hat - alles außer unserer Plattformgebühr. */
|
||||
export function stripeProcessingFeeCents(balanceTransaction: Stripe.BalanceTransaction): number {
|
||||
return balanceTransaction.fee_details
|
||||
.filter((detail) => detail.type !== "application_fee")
|
||||
.reduce((sum, detail) => sum + detail.amount, 0);
|
||||
}
|
||||
|
||||
async function storeFee(paymentIntentId: string, balanceTransaction: Stripe.BalanceTransaction) {
|
||||
await prisma.courseBooking.updateMany({
|
||||
where: { stripePaymentIntentId: paymentIntentId, stripeFee: null },
|
||||
data: { stripeFee: stripeProcessingFeeCents(balanceTransaction) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Einstieg 1, direkt nachdem der Webhook die Buchung angelegt hat. Steht die
|
||||
* Gebühr noch nicht fest, passiert nichts - charge.updated holt sie nach.
|
||||
*/
|
||||
export async function recordStripeFee(paymentIntentId: string, stripeAccountId: string) {
|
||||
const intent = await stripe.paymentIntents.retrieve(
|
||||
paymentIntentId,
|
||||
{ expand: ["latest_charge.balance_transaction"] },
|
||||
{ stripeAccount: stripeAccountId }
|
||||
);
|
||||
const charge = intent.latest_charge;
|
||||
if (!charge || typeof charge === "string") return;
|
||||
const balanceTransaction = charge.balance_transaction;
|
||||
if (!balanceTransaction || typeof balanceTransaction === "string") return;
|
||||
|
||||
await storeFee(paymentIntentId, balanceTransaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einstieg 2, aus dem charge.updated-Event. charge.updated feuert auch bei
|
||||
* Erstattungen und anderen Änderungen - deshalb zuerst in der DB prüfen, ob
|
||||
* überhaupt eine Buchung auf ihre Gebühr wartet, bevor Stripe gefragt wird.
|
||||
* Kommt das Event vor der Buchung an, findet sich hier nichts; dann holt
|
||||
* Einstieg 1 die Gebühr ab.
|
||||
*/
|
||||
export async function recordStripeFeeForCharge(charge: Stripe.Charge, stripeAccountId: string) {
|
||||
const paymentIntentId =
|
||||
typeof charge.payment_intent === "string" ? charge.payment_intent : charge.payment_intent?.id;
|
||||
const balanceTransactionId =
|
||||
typeof charge.balance_transaction === "string"
|
||||
? charge.balance_transaction
|
||||
: charge.balance_transaction?.id;
|
||||
if (!paymentIntentId || !balanceTransactionId) return;
|
||||
|
||||
const waiting = await prisma.courseBooking.count({
|
||||
where: { stripePaymentIntentId: paymentIntentId, stripeFee: null },
|
||||
});
|
||||
if (waiting === 0) return;
|
||||
|
||||
const balanceTransaction = await stripe.balanceTransactions.retrieve(
|
||||
balanceTransactionId,
|
||||
undefined,
|
||||
{ stripeAccount: stripeAccountId }
|
||||
);
|
||||
await storeFee(paymentIntentId, balanceTransaction);
|
||||
}
|
||||
|
|
@ -5,26 +5,51 @@ export type RefundResult =
|
|||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Erstattet eine Zahlung über Stripe. Checkouts laufen als Connect-Destination-Charges
|
||||
* (Geld wird per transfer_data.destination an den Business-Account transferiert),
|
||||
* deshalb sind reverse_transfer (holt das Geld vom verbundenen Account zurück) und
|
||||
* refund_application_fee (erstattet den anteiligen Plattform-Fee mit) zwingend nötig -
|
||||
* ohne reverse_transfer würde die Erstattung fehlschlagen, da das Geld beim Business liegt.
|
||||
* Erstattet eine Kurszahlung über Stripe.
|
||||
*
|
||||
* Wo das Geld herkommt, hängt davon ab, wo die Zahlung liegt:
|
||||
* - Direct Charge (stripeAccountId gesetzt): Die Zahlung liegt auf dem
|
||||
* Studio-Account, die Erstattung geht direkt von dessen Guthaben ab.
|
||||
* - Altbuchung (stripeAccountId null): Destination Charge auf dem
|
||||
* Plattformkonto. Das Geld liegt aber längst beim Studio, deshalb holt
|
||||
* reverse_transfer es dort zurück - ohne würde die Plattform die
|
||||
* Erstattung aus eigener Tasche zahlen.
|
||||
*
|
||||
* `refundApplicationFee` betrifft NICHT den Kunden. Eine erstattete
|
||||
* Plattformgebühr landet beim Studio (im Ledger als `adjustment`), in beiden
|
||||
* Modellen. Was der Kunde zurückbekommt, bestimmt allein `amountCents`. Das
|
||||
* Flag entscheidet also nur, ob Kumasy seine 0,50 € behält:
|
||||
* - false: Kumasy behält sie. Stornierung nach Stornobedingungen und
|
||||
* Kursabsage durch das Studio - wer absagt, trägt die Kosten seiner Absage.
|
||||
* - true: Kumasy gibt sie an das Studio zurück. Für Erstattungen, die die
|
||||
* Plattform zu verantworten hat: Überbuchung, Doppelzahlung, abgelehnte
|
||||
* Buchung.
|
||||
* Bewusst ein Pflichtfeld: jeder Aufrufer soll diese Entscheidung treffen.
|
||||
*/
|
||||
export async function refundStripePayment(
|
||||
paymentIntentId: string,
|
||||
amountCents?: number, // undefined = volle Erstattung
|
||||
idempotencyKey?: string
|
||||
): Promise<RefundResult> {
|
||||
export async function refundStripePayment(params: {
|
||||
paymentIntentId: string;
|
||||
/** Account, auf dem die Zahlung liegt. null = Altbuchung auf der Plattform. */
|
||||
stripeAccountId: string | null;
|
||||
/** undefined = volle Erstattung des gesamten gezahlten Betrags. */
|
||||
amountCents?: number;
|
||||
idempotencyKey?: string;
|
||||
refundApplicationFee: boolean;
|
||||
}): Promise<RefundResult> {
|
||||
const { paymentIntentId, stripeAccountId, amountCents, idempotencyKey, refundApplicationFee } =
|
||||
params;
|
||||
|
||||
try {
|
||||
const refund = await stripe.refunds.create(
|
||||
{
|
||||
payment_intent: paymentIntentId,
|
||||
...(amountCents !== undefined ? { amount: amountCents } : {}),
|
||||
reverse_transfer: true,
|
||||
refund_application_fee: true,
|
||||
refund_application_fee: refundApplicationFee,
|
||||
...(stripeAccountId ? {} : { reverse_transfer: true }),
|
||||
},
|
||||
idempotencyKey ? { idempotencyKey } : undefined
|
||||
{
|
||||
...(stripeAccountId ? { stripeAccount: stripeAccountId } : {}),
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
}
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,20 @@
|
|||
/**
|
||||
* Zahlungsfluss bei Kumasy - Stripe Connect mit Direct Charges.
|
||||
*
|
||||
* 1. Checkout (api/stripe/checkout/course): Die Checkout-Session entsteht direkt
|
||||
* auf dem Express-Account des Studios. Der Kunde zahlt Kurspreis plus
|
||||
* Servicegebühr; die Servicegebühr geht als application_fee an Kumasy.
|
||||
* 2. Webhook (api/stripe/webhook): Stripe meldet die Zahlung als Connect-Event,
|
||||
* `event.account` ist der Studio-Account. Erst dort entsteht die Buchung -
|
||||
* nie auf der Success-Page.
|
||||
* 3. Stripe-Gebühr: zieht Stripe direkt beim Studio ab. Kumasy liest sie nur
|
||||
* aus, um sie im Zahlungen-Tab zu zeigen (stripe-fee.ts).
|
||||
* 4. Erstattungen (stripe-refund.ts): gehen vom Guthaben des Studios ab.
|
||||
*
|
||||
* Buchungen von vor der Umstellung liegen als Destination Charges auf dem
|
||||
* Plattformkonto (CourseBooking.stripeAccountId = null). Sie werden weiterhin
|
||||
* über die Plattform mit reverse_transfer erstattet.
|
||||
*/
|
||||
import Stripe from "stripe";
|
||||
|
||||
let _stripe: Stripe | null = null;
|
||||
|
|
@ -21,46 +38,62 @@ export const stripe = new Proxy({} as Stripe, {
|
|||
});
|
||||
|
||||
/**
|
||||
* Liefert das Webhook-Secret und wirft, wenn es fehlt.
|
||||
* Lazy ausgewertet (nicht auf Modul-Ebene), damit `next build` nicht abbricht,
|
||||
* wenn die ENV-Variable beim Build (z.B. in CI) noch nicht gesetzt ist.
|
||||
* Request-Optionen für einen Aufruf auf dem Account, auf dem eine Zahlung liegt.
|
||||
* Direct Charges leben auf dem Studio-Account und sind nur mit dessen ID
|
||||
* erreichbar; Altbuchungen (null) liegen auf dem Plattformkonto selbst.
|
||||
*/
|
||||
export function getWebhookSecret(): string {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
export function onAccount(stripeAccountId: string | null): Stripe.RequestOptions | undefined {
|
||||
return stripeAccountId ? { stripeAccount: stripeAccountId } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle Signing-Secrets, gegen die eingehende Webhooks geprüft werden,
|
||||
* und wirft, wenn keines gesetzt ist.
|
||||
*
|
||||
* Zwei Secrets, weil Stripe zwei Arten von Events schickt: Zahlungen laufen
|
||||
* als Direct Charges auf den Studio-Accounts und kommen deshalb als
|
||||
* Connect-Events. Im Stripe-Dashboard ist das ein eigener Endpoint
|
||||
* ("Ereignisse auf verbundenen Konten") mit eigenem Secret, der auf dieselbe
|
||||
* Route zeigt. Lokal signiert die Stripe CLI beide Arten mit demselben Secret,
|
||||
* dort genügt STRIPE_WEBHOOK_SECRET.
|
||||
*
|
||||
* Lazy ausgewertet (nicht auf Modul-Ebene), damit `next build` nicht abbricht,
|
||||
* wenn die ENV-Variablen beim Build (z.B. in CI) noch nicht gesetzt sind.
|
||||
*/
|
||||
export function getWebhookSecrets(): string[] {
|
||||
const secrets = [process.env.STRIPE_WEBHOOK_SECRET, process.env.STRIPE_CONNECT_WEBHOOK_SECRET]
|
||||
.map((secret) => secret?.trim())
|
||||
.filter((secret): secret is string => Boolean(secret));
|
||||
if (secrets.length === 0) {
|
||||
throw new Error("STRIPE_WEBHOOK_SECRET ist nicht gesetzt");
|
||||
}
|
||||
return secret;
|
||||
return secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die validierte Plattform-Fee in Prozent (Default 5%).
|
||||
* Lazy ausgewertet, damit ein ungültiger Wert nicht den Build crasht.
|
||||
* Liefert die Plattform-Servicegebühr in Cents (Default 50 = 0,50 €).
|
||||
*
|
||||
* Fixbetrag pro Buchung, den der KUNDE zusätzlich zum Kurspreis zahlt (eigene
|
||||
* Position im Checkout). Das Studio erhält dadurch den vollen Kurspreis
|
||||
* (abzüglich der Stripe-Gebühr, die Stripe direkt beim Studio einbehält),
|
||||
* während application_fee_amount exakt diesen Betrag bei der Plattform
|
||||
* einbehält. Lazy ausgewertet, damit ein ungültiger Wert nicht den Build
|
||||
* crasht.
|
||||
*/
|
||||
export function getPlatformFeePercent(): number {
|
||||
const raw = process.env.STRIPE_PLATFORM_FEE_PERCENT;
|
||||
export function getPlatformFeeCents(): number {
|
||||
const raw = process.env.STRIPE_PLATFORM_FEE_CENTS;
|
||||
// Leerer/Whitespace-String würde via Number("") still zu 0 werden und so
|
||||
// die Fee unbemerkt auf 0 setzen. Explizit ablehnen.
|
||||
// die Gebühr unbemerkt auf 0 setzen. Explizit ablehnen.
|
||||
if (raw?.trim() === "") {
|
||||
throw new Error("STRIPE_PLATFORM_FEE_PERCENT darf nicht leer sein");
|
||||
throw new Error("STRIPE_PLATFORM_FEE_CENTS darf nicht leer sein");
|
||||
}
|
||||
const value = Number(raw ?? "5");
|
||||
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
||||
throw new Error("STRIPE_PLATFORM_FEE_PERCENT muss zwischen 0 und 100 liegen");
|
||||
const value = Number(raw ?? "50");
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error("STRIPE_PLATFORM_FEE_CENTS muss eine ganze Zahl >= 0 sein (Cents)");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Plattform-Fee in Cents.
|
||||
* @param amountEuros - Kurs-Preis in EUR
|
||||
* @returns Fee-Betrag in Cents (für Stripe API)
|
||||
*/
|
||||
export function calculatePlatformFeeCents(amountEuros: number): number {
|
||||
const amountCents = Math.round(amountEuros * 100);
|
||||
return Math.round((amountCents * getPlatformFeePercent()) / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert EUR zu Cents für Stripe API
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -227,9 +227,13 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
try {
|
||||
const userData = await getUserData();
|
||||
|
||||
// isLoggedIn sagt dem Buchungsdialog, ob er Gästen gleich das Gast-
|
||||
// Formular zeigen soll, statt sie erst einen Buchungsversuch scheitern
|
||||
// zu lassen.
|
||||
if (!userData) {
|
||||
return {
|
||||
success: true,
|
||||
isLoggedIn: false,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
};
|
||||
|
|
@ -248,6 +252,7 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
|
||||
return {
|
||||
success: true,
|
||||
isLoggedIn: true,
|
||||
isBooked: booking?.paymentStatus === "paid",
|
||||
isOnWaitlist: waitlistEntry !== null,
|
||||
};
|
||||
|
|
@ -256,6 +261,9 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Prüfen des Buchungsstatus",
|
||||
// Unbekannt - der Dialog fällt dann auf den alten Weg zurück und zeigt
|
||||
// das Gast-Formular erst, wenn die Buchung "nicht angemeldet" meldet.
|
||||
isLoggedIn: null,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { refundStripePayment } from "@/src/lib/stripe-refund";
|
|||
import { sendMail } from "@/src/lib/mail/resend";
|
||||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
import { courseAmountCents } from "@/src/lib/booking-amounts";
|
||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { notifyCourseWaitlist } from "./notify-waitlist";
|
||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||
|
|
@ -107,7 +108,10 @@ export async function executeBookingCancellation(
|
|||
select: {
|
||||
paymentStatus: true,
|
||||
amountPaid: true,
|
||||
platformFee: true,
|
||||
platformFeeOnTop: true,
|
||||
stripePaymentIntentId: true,
|
||||
stripeAccountId: true,
|
||||
userId: true,
|
||||
guestLeadId: true,
|
||||
course: {
|
||||
|
|
@ -133,6 +137,12 @@ export async function executeBookingCancellation(
|
|||
|
||||
const forceFullRefund = options?.forceFullRefund ?? false;
|
||||
|
||||
// Stornoquote und Erstattungsbetrag beziehen sich auf den KURSANTEIL. Die
|
||||
// Servicegebühr hat der Kunde zusätzlich gezahlt; sie ist Leistung der
|
||||
// Plattform und wird bei einer regulären Stornierung einbehalten. Nur wenn
|
||||
// der Kurs komplett ausfällt (forceFullRefund), geht auch sie zurück.
|
||||
const courseAmount = courseAmountCents(booking);
|
||||
|
||||
if (!forceFullRefund) {
|
||||
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
|
||||
if (courseStart < new Date()) {
|
||||
|
|
@ -148,10 +158,13 @@ export async function executeBookingCancellation(
|
|||
// gesperrte Stornierung die Buchung trotzdem in den Zwischenstatus schieben.
|
||||
// Bei forceFullRefund entfällt diese Prüfung komplett (siehe Docblock oben).
|
||||
const eligibility = forceFullRefund
|
||||
? { cancellationAllowed: true, refundPercent: 100, refundAmountCents: booking.amountPaid }
|
||||
? // Kursabsage durch das Studio: den Kunden trifft keine Schuld, er bekommt
|
||||
// den vollen gezahlten Betrag inklusive Servicegebühr zurück - finanziert
|
||||
// aus der Erstattungssumme, nicht über refund_application_fee.
|
||||
{ cancellationAllowed: true, refundPercent: 100, refundAmountCents: booking.amountPaid }
|
||||
: await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
amountPaidCents: courseAmount,
|
||||
courseStart: combineDateAndTime(booking.course.date, booking.course.timeFrom),
|
||||
now: new Date(),
|
||||
});
|
||||
|
|
@ -186,11 +199,25 @@ export async function executeBookingCancellation(
|
|||
// überhaupt zu kontaktieren.
|
||||
let amountRefundedCents = 0;
|
||||
if (refundAmountCents > 0) {
|
||||
const refundResult = await refundStripePayment(
|
||||
booking.stripePaymentIntentId,
|
||||
refundAmountCents < booking.amountPaid ? refundAmountCents : undefined,
|
||||
`refund-${bookingId}`
|
||||
);
|
||||
// Die Erstattung geht vom Guthaben des Studios ab (Direct Charge) bzw. wird
|
||||
// bei Altbuchungen per reverse_transfer dort zurückgeholt - beides
|
||||
// entscheidet refundStripePayment anhand von stripeAccountId.
|
||||
const refundResult = await refundStripePayment({
|
||||
paymentIntentId: booking.stripePaymentIntentId,
|
||||
stripeAccountId: booking.stripeAccountId,
|
||||
// undefined hieße "komplette Charge" - und die enthält seit der fixen
|
||||
// Servicegebühr mehr als den Kursanteil. Deshalb wird der Betrag bei
|
||||
// jeder regulären Stornierung explizit gesetzt und nur bei der
|
||||
// Kursabsage das Voll-Erstattungs-Verhalten genutzt.
|
||||
amountCents:
|
||||
forceFullRefund && refundAmountCents >= booking.amountPaid ? undefined : refundAmountCents,
|
||||
idempotencyKey: `refund-${bookingId}`,
|
||||
// Die Servicegebühr bleibt bei Kumasy - auch bei einer Kursabsage. Sie
|
||||
// zurückzugeben würde den Kunden nicht besserstellen (der bekommt seinen
|
||||
// vollen Betrag über die Erstattungssumme), sondern sie nur an das Studio
|
||||
// abgeben, das den Kurs abgesagt hat.
|
||||
refundApplicationFee: false,
|
||||
});
|
||||
|
||||
if (!refundResult.success) {
|
||||
// WICHTIG: paymentStatus zurück auf "paid" setzen, wenn Stripe ablehnt -
|
||||
|
|
@ -208,8 +235,12 @@ export async function executeBookingCancellation(
|
|||
// 0% Rückerstattung storniert die Buchung trotzdem, aber ohne Geldbewegung -
|
||||
// dafür gibt es einen eigenen Status, damit weder die Rechnungs-/Metrik-Logik
|
||||
// noch die Storno-Mail fälschlich "erstattet" suggerieren (siehe §"cancelled").
|
||||
// "Vollständig erstattet" heißt: alles, was in diesem Fall erstattungsfähig
|
||||
// war, ist zurück - bei regulärer Stornierung der Kursanteil, bei einer
|
||||
// Kursabsage der komplette gezahlte Betrag.
|
||||
const fullyRefundableCents = forceFullRefund ? booking.amountPaid : courseAmount;
|
||||
const newPaymentStatus =
|
||||
amountRefundedCents >= booking.amountPaid
|
||||
amountRefundedCents >= fullyRefundableCents
|
||||
? "refunded"
|
||||
: amountRefundedCents > 0
|
||||
? "partially_refunded"
|
||||
|
|
@ -229,7 +260,9 @@ export async function executeBookingCancellation(
|
|||
const emailPromise = sendCancellationEmail(
|
||||
booking,
|
||||
amountRefundedCents,
|
||||
booking.amountPaid,
|
||||
// Bezugsgröße der Stornoquote in der Mail - der Kursanteil, nicht die
|
||||
// Charge inklusive Gebühr.
|
||||
courseAmount,
|
||||
refundPercent,
|
||||
cancelledBy,
|
||||
forceFullRefund ? "course_cancelled" : undefined
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
// bestehende Importe aus diesem Modul weiter funktionieren.
|
||||
export { combineDateAndTime } from "@/src/lib/course-time";
|
||||
|
||||
const DEFAULT_POLICY: CancellationPolicy = {
|
||||
export const DEFAULT_POLICY: CancellationPolicy = {
|
||||
enabled: true,
|
||||
mode: "tiered",
|
||||
fixedRefundPercent: 100,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
|
||||
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
|
||||
import { courseAmountCents } from "@/src/lib/booking-amounts";
|
||||
|
||||
export type CancellationPreview = {
|
||||
cancellationAllowed: boolean;
|
||||
|
|
@ -35,6 +36,8 @@ export async function previewBookingCancellation(
|
|||
select: {
|
||||
userId: true,
|
||||
amountPaid: true,
|
||||
platformFee: true,
|
||||
platformFeeOnTop: true,
|
||||
course: { select: { date: true, timeFrom: true, businessId: true } },
|
||||
},
|
||||
});
|
||||
|
|
@ -55,10 +58,15 @@ export async function previewBookingCancellation(
|
|||
|
||||
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
|
||||
|
||||
// Identische Bezugsgröße wie in executeBookingCancellation: der Kursanteil
|
||||
// ohne Servicegebühr. Sonst würde der Dialog eine höhere Erstattung
|
||||
// ankündigen, als die Stornierung anschließend auszahlt.
|
||||
const courseAmount = courseAmountCents(booking);
|
||||
|
||||
const { cancellationAllowed, refundPercent, refundAmountCents } =
|
||||
await calculateRefundAmountCents({
|
||||
businessId: booking.course.businessId,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
amountPaidCents: courseAmount,
|
||||
courseStart,
|
||||
now: new Date(),
|
||||
});
|
||||
|
|
@ -69,7 +77,7 @@ export async function previewBookingCancellation(
|
|||
cancellationAllowed,
|
||||
refundPercent,
|
||||
refundAmountCents,
|
||||
amountPaidCents: booking.amountPaid,
|
||||
amountPaidCents: courseAmount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { prisma } from "@/src/lib/prisma";
|
|||
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
|
||||
import { courseSchema, publishedCourseSchema, type CourseFormData } from "../schemas/course-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { refreshPublicCourses } from "../lib/public-courses-cache";
|
||||
import { Prisma } from "@/src/generated/prisma/client";
|
||||
import { generateUniqueSlug } from "@/src/lib/slug";
|
||||
import {
|
||||
|
|
@ -169,6 +170,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
|||
|
||||
// Next.js Cache für /courses Seite invalidieren
|
||||
revalidatePath("/courses");
|
||||
refreshPublicCourses(validatedData.businessId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -584,6 +586,7 @@ export async function updateCourse(
|
|||
// Schritt 9: Next.js Cache für /courses Seite invalidieren
|
||||
revalidatePath("/courses");
|
||||
revalidatePath(`/courses/edit/${courseId}`);
|
||||
refreshPublicCourses(existingCourse.businessId, validatedData.businessId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -635,6 +638,7 @@ export async function updateCourse(
|
|||
// Schritt 9: Next.js Cache für /courses Seite invalidieren
|
||||
revalidatePath("/courses");
|
||||
revalidatePath(`/courses/edit/${courseId}`);
|
||||
refreshPublicCourses(existingCourse.businessId, validatedData.businessId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -725,6 +729,7 @@ export async function deleteCourse(courseId: string, scope: StandingOrderScope =
|
|||
|
||||
// Schritt 8: Next.js Cache invalidieren
|
||||
revalidatePath("/courses");
|
||||
refreshPublicCourses(course.businessId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -863,6 +868,7 @@ export async function toggleBusinessPublic(businessId: string, isPublic: boolean
|
|||
|
||||
revalidatePath("/settings");
|
||||
revalidatePath(`/business/${slug}`);
|
||||
refreshPublicCourses(businessId);
|
||||
|
||||
return { success: true, slug };
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { prisma } from "@/src/lib/prisma";
|
|||
import { getUserData, isManager, isEmployee, getEffectiveManagerId } from "@/src/lib/auth/getUser";
|
||||
import { publishedCourseSchema } from "../schemas/course-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { refreshPublicCourses } from "../lib/public-courses-cache";
|
||||
import { Prisma } from "@/src/generated/prisma/client";
|
||||
|
||||
/** Ein Entwurf in der Vorschau, inkl. Ergebnis der Veröffentlichbarkeits-Prüfung. */
|
||||
|
|
@ -245,6 +246,7 @@ export async function publishDraftCourses(courseIds: string[]) {
|
|||
let vanishedCount = courseIds.filter((id) => !foundIds.has(id)).length;
|
||||
|
||||
let publishedIds: string[] = [];
|
||||
let publishedBusinessIds: (string | null)[] = [];
|
||||
if (publishableIds.length > 0) {
|
||||
// updateManyAndReturn liefert genau die Zeilen zurück, die DIESES Statement
|
||||
// geändert hat. Ein nachgelagertes findMany auf status "published" würde
|
||||
|
|
@ -265,10 +267,11 @@ export async function publishDraftCourses(courseIds: string[]) {
|
|||
isStandingOrder: false,
|
||||
},
|
||||
data: { status: "published" },
|
||||
select: { id: true },
|
||||
select: { id: true, businessId: true },
|
||||
});
|
||||
|
||||
publishedIds = updated.map((c) => c.id);
|
||||
publishedBusinessIds = updated.map((c) => c.businessId);
|
||||
|
||||
// Differenz = Kurse, die zwischen Prüfung und Update von woanders
|
||||
// verändert wurden. Sie zählen als übersprungen, nicht als veröffentlicht.
|
||||
|
|
@ -276,6 +279,7 @@ export async function publishDraftCourses(courseIds: string[]) {
|
|||
}
|
||||
|
||||
revalidatePath("/courses");
|
||||
refreshPublicCourses(...publishedBusinessIds);
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
|
|
|
|||
89
src/modules/courses/lib/calendar-layout.ts
Normal file
89
src/modules/courses/lib/calendar-layout.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Platzierung von Kursen im Zeitraster der Wochenansicht.
|
||||
*
|
||||
* Ein Kurs wird ein Block an seiner Uhrzeit, so hoch wie er dauert. Kurse,
|
||||
* die sich zeitlich überschneiden, teilen sich die Spaltenbreite und stehen
|
||||
* nebeneinander - wie in Apple- oder Google-Kalender.
|
||||
*/
|
||||
|
||||
export function timeToMinutes(time: string): number {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
return hours * 60 + (minutes || 0);
|
||||
}
|
||||
|
||||
type Timed = { timeFrom: string; timeTo: string };
|
||||
|
||||
/** Beginn und Ende in Minuten; ohne gültiges Ende wird eine Stunde angenommen. */
|
||||
function span(course: Timed): { start: number; end: number } {
|
||||
const start = timeToMinutes(course.timeFrom);
|
||||
const end = timeToMinutes(course.timeTo);
|
||||
return { start, end: end > start ? end : start + 60 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Angezeigter Stundenbereich: von der vollen Stunde vor dem ersten bis nach
|
||||
* dem letzten Kurs, mindestens acht Stunden, ohne Kurse 8 bis 20 Uhr.
|
||||
*/
|
||||
export function visibleHourRange(courses: Timed[]): { startHour: number; endHour: number } {
|
||||
if (courses.length === 0) return { startHour: 8, endHour: 20 };
|
||||
const spans = courses.map(span);
|
||||
let startHour = Math.floor(Math.min(...spans.map((s) => s.start)) / 60);
|
||||
let endHour = Math.ceil(Math.max(...spans.map((s) => s.end)) / 60);
|
||||
while (endHour - startHour < 8) {
|
||||
if (startHour > 6) startHour -= 1;
|
||||
else if (endHour < 24) endHour += 1;
|
||||
else break;
|
||||
}
|
||||
return { startHour: Math.max(0, startHour), endHour: Math.min(24, endHour) };
|
||||
}
|
||||
|
||||
export type PlacedCourse<T> = {
|
||||
course: T;
|
||||
start: number;
|
||||
end: number;
|
||||
/** Spalte innerhalb einer Gruppe sich überschneidender Kurse. */
|
||||
lane: number;
|
||||
/** Wie viele Spalten diese Gruppe braucht. */
|
||||
laneCount: number;
|
||||
};
|
||||
|
||||
/** Ordnet die Kurse eines Tages Spalten zu, sodass sich keine zwei überdecken. */
|
||||
export function layoutDay<T extends Timed>(courses: T[]): PlacedCourse<T>[] {
|
||||
const items = courses
|
||||
.map((course) => ({ course, ...span(course), lane: 0, laneCount: 1 }))
|
||||
.sort((a, b) => a.start - b.start || b.end - a.end);
|
||||
|
||||
const placed: PlacedCourse<T>[] = [];
|
||||
let group: typeof items = [];
|
||||
let laneEnds: number[] = [];
|
||||
let groupEnd = -1;
|
||||
|
||||
const closeGroup = () => {
|
||||
for (const item of group) item.laneCount = laneEnds.length;
|
||||
placed.push(...group);
|
||||
group = [];
|
||||
laneEnds = [];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
// Beginnt der Kurs nach dem Ende aller bisherigen, startet eine neue Gruppe.
|
||||
if (item.start >= groupEnd) closeGroup();
|
||||
let lane = laneEnds.findIndex((end) => end <= item.start);
|
||||
if (lane === -1) lane = laneEnds.length;
|
||||
laneEnds[lane] = item.end;
|
||||
item.lane = lane;
|
||||
group.push(item);
|
||||
groupEnd = Math.max(groupEnd, item.end);
|
||||
}
|
||||
closeGroup();
|
||||
return placed;
|
||||
}
|
||||
|
||||
/** Kalenderwoche nach ISO 8601 für den Montag einer Woche ("2026-09-28" → 40). */
|
||||
export function isoWeekNumber(mondayKey: string): number {
|
||||
const [year, month, day] = mondayKey.split("-").map(Number);
|
||||
// Die KW gehört zu dem Jahr, in dem der Donnerstag der Woche liegt.
|
||||
const thursday = new Date(Date.UTC(year, month - 1, day + 3));
|
||||
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1);
|
||||
return Math.ceil(((thursday.getTime() - yearStart) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
32
src/modules/courses/lib/cancellation-summary.ts
Normal file
32
src/modules/courses/lib/cancellation-summary.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { CancellationPolicy } from "@/src/modules/settings/schemas/cancellation-policy-schema";
|
||||
|
||||
/**
|
||||
* Die Stornoregel eines Studios in einem Satz, wie Kunden sie vor dem Buchen
|
||||
* lesen. Folgt exakt der Logik von selectRefundPercent: Stufen gelten, sobald
|
||||
* weniger als ihre Stundenzahl bis Kursbeginn übrig ist; mehr Vorlauf als die
|
||||
* größte Stufe heißt volle Erstattung; ohne Stufen immer volle Erstattung.
|
||||
*/
|
||||
export function describeCancellationPolicy(policy: CancellationPolicy): string {
|
||||
if (!policy.enabled) {
|
||||
return "Gebuchte Kurse können nicht online storniert werden.";
|
||||
}
|
||||
|
||||
if (policy.mode === "fixed") {
|
||||
if (policy.fixedRefundPercent >= 100) return "Kostenlos stornierbar bis Kursbeginn.";
|
||||
if (policy.fixedRefundPercent <= 0) return "Bei einer Stornierung gibt es keine Erstattung.";
|
||||
return `Bei einer Stornierung werden ${policy.fixedRefundPercent} % erstattet.`;
|
||||
}
|
||||
|
||||
if (policy.tiers.length === 0) return "Kostenlos stornierbar bis Kursbeginn.";
|
||||
|
||||
const tiers = [...policy.tiers].sort((a, b) => b.hoursBeforeStart - a.hoursBeforeStart);
|
||||
const refund = (percent: number) =>
|
||||
percent <= 0 ? "keine Erstattung" : `${percent} % Erstattung`;
|
||||
|
||||
const parts = [`Kostenlos stornierbar bis ${tiers[0].hoursBeforeStart} Stunden vor Kursbeginn`];
|
||||
parts.push(`danach ${refund(tiers[0].refundPercent)}`);
|
||||
for (const tier of tiers.slice(1)) {
|
||||
parts.push(`ab ${tier.hoursBeforeStart} Stunden vorher ${refund(tier.refundPercent)}`);
|
||||
}
|
||||
return `${parts.join(", ")}.`;
|
||||
}
|
||||
127
src/modules/courses/lib/course-days.ts
Normal file
127
src/modules/courses/lib/course-days.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Kalendertage für Kursansichten.
|
||||
*
|
||||
* Kurse liegen in der DB in den ersten Stunden ihres Tages (das Datumsfeld
|
||||
* "2026-10-15" wird zu 2026-10-15T00:00:00Z, ältere Serien auch 01:00 oder
|
||||
* 02:00 UTC). In Berlin ist das immer derselbe Kalendertag. Tage werden
|
||||
* deshalb überall explizit in Berliner Zeit bestimmt - nie über die lokale
|
||||
* Zeitzone von Server oder Browser.
|
||||
* Ein Tag wird als Schlüssel "YYYY-MM-DD" geführt; der lässt sich als Text
|
||||
* sortieren und vergleichen.
|
||||
*/
|
||||
|
||||
export const BERLIN = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: BERLIN,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
|
||||
/** Kalendertag in Berliner Zeit als "2026-10-02". */
|
||||
export function dayKey(date: Date | string): string {
|
||||
return dayKeyFormatter.format(new Date(date));
|
||||
}
|
||||
|
||||
/** Mittag UTC liegt in Berlin immer am selben Kalendertag - sicher zum Formatieren. */
|
||||
export function keyToNoon(key: string): Date {
|
||||
return new Date(`${key}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDaysToKey(key: string, days: number): string {
|
||||
return dayKey(new Date(keyToNoon(key).getTime() + days * DAY_MS));
|
||||
}
|
||||
|
||||
const berlinHourFormatter = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: BERLIN,
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** Mitternacht Berliner Zeit zu Beginn dieses Tages - untere Grenze für Abfragen. */
|
||||
export function berlinDayStart(key: string): Date {
|
||||
// Um 00:00 UTC ist es in Berlin 01:00 (MEZ) oder 02:00 (MESZ); die
|
||||
// Zeitumstellung passiert erst um 01:00 UTC. Der dort abgelesene Versatz
|
||||
// gilt also auch für die Berliner Mitternacht davor.
|
||||
const utcMidnight = new Date(`${key}T00:00:00Z`);
|
||||
const offsetHours = Number(berlinHourFormatter.format(utcMidnight));
|
||||
return new Date(utcMidnight.getTime() - offsetHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/** Letzte Millisekunde dieses Berliner Tages - obere Grenze für "bis einschließlich". */
|
||||
export function berlinDayEnd(key: string): Date {
|
||||
return new Date(berlinDayStart(addDaysToKey(key, 1)).getTime() - 1);
|
||||
}
|
||||
|
||||
/** Der Kalender arbeitet mit lokalen Datumsobjekten. */
|
||||
export function keyToLocalDate(key: string): Date {
|
||||
const [year, month, day] = key.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function localDateToKey(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** "Heute · Donnerstag, 1. Oktober", "Morgen · …" oder nur das Datum. */
|
||||
export function formatDayHeading(key: string, todayKey: string): string {
|
||||
const label = keyToNoon(key).toLocaleDateString("de-DE", {
|
||||
timeZone: BERLIN,
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
if (key === todayKey) return `Heute · ${label}`;
|
||||
if (key === addDaysToKey(todayKey, 1)) return `Morgen · ${label}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Kurzform für Chips und Buttons, z.B. "Fr., 30. Oktober". */
|
||||
export function formatShortDay(key: string): string {
|
||||
return keyToNoon(key).toLocaleDateString("de-DE", {
|
||||
timeZone: BERLIN,
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
});
|
||||
}
|
||||
|
||||
/** Wochentag eines Tages-Schlüssels: 0 = Sonntag … 6 = Samstag. */
|
||||
export function weekdayOfKey(key: string): number {
|
||||
return keyToNoon(key).getUTCDay();
|
||||
}
|
||||
|
||||
/** Montag der Woche, in der dieser Tag liegt. */
|
||||
export function startOfWeekKey(key: string): string {
|
||||
const daysSinceMonday = (weekdayOfKey(key) + 6) % 7;
|
||||
return addDaysToKey(key, -daysSinceMonday);
|
||||
}
|
||||
|
||||
/** Die sieben Tage einer Woche ab Montag. */
|
||||
export function weekDayKeys(mondayKey: string): string[] {
|
||||
return Array.from({ length: 7 }, (_, i) => addDaysToKey(mondayKey, i));
|
||||
}
|
||||
|
||||
/** Kursdauer in Minuten aus "07:30" und "08:45"; null bei unplausiblen Werten. */
|
||||
export function durationMinutes(timeFrom: string, timeTo: string): number | null {
|
||||
const toMinutes = (time: string) => {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
const duration = toMinutes(timeTo) - toMinutes(timeFrom);
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
}
|
||||
|
||||
/** Aktuelle Uhrzeit in Berlin als "HH:MM" - zum Ausblenden bereits begonnener Kurse. */
|
||||
export function berlinTimeNow(): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: BERLIN,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(new Date());
|
||||
}
|
||||
7
src/modules/courses/lib/course-levels.ts
Normal file
7
src/modules/courses/lib/course-levels.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/** Bezeichnung und Farben der Kurs-Level - gemeinsam für Karte, Dialog und Stundenplan. */
|
||||
export const COURSE_LEVELS: Record<string, { label: string; bg: string; text: string }> = {
|
||||
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
|
||||
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
|
||||
advanced: { label: "Fortgeschrittene", bg: "bg-orange-200", text: "text-orange-700" },
|
||||
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
|
||||
};
|
||||
17
src/modules/courses/lib/public-courses-cache.ts
Normal file
17
src/modules/courses/lib/public-courses-cache.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { updateTag } from "next/cache";
|
||||
|
||||
/**
|
||||
* Die öffentliche Studio-Seite speichert ihre Kursliste zwischen (siehe
|
||||
* getPublishedCoursesForBusiness). Nach jeder Änderung an Kursen muss dieser
|
||||
* Cache sofort weg - sonst sieht ein Kunde einen neu angelegten oder
|
||||
* veröffentlichten Kurs erst nach Ablauf des Caches und erneutem Laden.
|
||||
*
|
||||
* updateTag statt revalidateTag: Es verwirft sofort, der nächste Aufruf wartet
|
||||
* auf frische Daten statt noch einmal die alte Liste zu liefern. Funktioniert
|
||||
* nur innerhalb einer Server Action.
|
||||
*/
|
||||
export function refreshPublicCourses(...businessIds: (string | null | undefined)[]) {
|
||||
for (const businessId of new Set(businessIds)) {
|
||||
if (businessId) updateTag(`business-courses-${businessId}`);
|
||||
}
|
||||
}
|
||||
31
src/modules/courses/lib/sport-colors.ts
Normal file
31
src/modules/courses/lib/sport-colors.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Eine Farbe pro Kursart für Kalenderblöcke, Zeitleiste und Filter. Getönte
|
||||
* Fläche, kräftige Kante, dunkle Schrift - gut lesbar und ruhig, auch wenn
|
||||
* viele Kurse nebeneinander stehen.
|
||||
*/
|
||||
export type SportColor = { dot: string; bg: string; border: string; text: string };
|
||||
|
||||
const PALETTE: SportColor[] = [
|
||||
{ dot: "#6366f1", bg: "#eef2ff", border: "#6366f1", text: "#312e81" },
|
||||
{ dot: "#10b981", bg: "#ecfdf5", border: "#10b981", text: "#064e3b" },
|
||||
{ dot: "#f43f5e", bg: "#fff1f2", border: "#f43f5e", text: "#881337" },
|
||||
{ dot: "#0ea5e9", bg: "#f0f9ff", border: "#0ea5e9", text: "#0c4a6e" },
|
||||
{ dot: "#f59e0b", bg: "#fffbeb", border: "#f59e0b", text: "#78350f" },
|
||||
{ dot: "#8b5cf6", bg: "#f5f3ff", border: "#8b5cf6", text: "#4c1d95" },
|
||||
{ dot: "#14b8a6", bg: "#f0fdfa", border: "#14b8a6", text: "#134e4a" },
|
||||
{ dot: "#f97316", bg: "#fff7ed", border: "#f97316", text: "#7c2d12" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Ordnet jeder Kursart eine Farbe zu - alphabetisch, damit dieselbe Kursart
|
||||
* auf der Seite immer dieselbe Farbe trägt und benachbarte Arten sich
|
||||
* unterscheiden.
|
||||
*/
|
||||
export function sportColorMap(sports: string[]): Map<string, SportColor> {
|
||||
const sorted = Array.from(new Set(sports)).sort();
|
||||
return new Map(sorted.map((sport, i) => [sport, PALETTE[i % PALETTE.length]]));
|
||||
}
|
||||
|
||||
export function colorForSports(sports: string[], map: Map<string, SportColor>): SportColor {
|
||||
return map.get(sports[0]) ?? PALETTE[0];
|
||||
}
|
||||
|
|
@ -7,12 +7,13 @@ import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X } from "lucide-rea
|
|||
import { Button } from "@/src/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
|
||||
import DOMPurify from "dompurify";
|
||||
import { checkUserBookingStatus, previewDiscountCode } from "../../actions/booking-actions";
|
||||
import { joinWaitlistAsUser } from "../../actions/waitlist-actions";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
import { toast } from "sonner";
|
||||
import { COURSE_LEVELS } from "../../lib/course-levels";
|
||||
import { formatEuro } from "./course-schedule-shared";
|
||||
|
||||
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
||||
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
||||
|
|
@ -35,12 +36,7 @@ function useSanitizedHtml(dirty: string | undefined): string {
|
|||
return DOMPurify.sanitize(dirty);
|
||||
}
|
||||
|
||||
const levelConfig: Record<string, { label: string; bg: string; text: string }> = {
|
||||
any: { label: "Jedes Niveau", bg: "bg-gray-200", text: "text-gray-700" },
|
||||
beginner: { label: "Anfänger", bg: "bg-blue-200", text: "text-blue-700" },
|
||||
advanced: { label: "Fortgeschrittene", bg: "bg-orange-200", text: "text-orange-700" },
|
||||
pro: { label: "Profi", bg: "bg-red-200", text: "text-red-700" },
|
||||
};
|
||||
const levelConfig = COURSE_LEVELS;
|
||||
|
||||
/**
|
||||
* Einheitliche "voll"-Definition für Karte und Dialog - vorher wurde dieselbe
|
||||
|
|
@ -84,15 +80,6 @@ export interface PublicCourse {
|
|||
trainerProfiles?: TrainerProfile[];
|
||||
}
|
||||
|
||||
interface PublicCourseCardProps {
|
||||
course: PublicCourse;
|
||||
business?: BusinessInfo;
|
||||
defaultOpen?: boolean; // öffnet das Detail-Popup beim Mount (für Deep-Links)
|
||||
/** Business-Akzentfarbe für Buchen-Button/Fortschrittsbalken. Ohne Wert
|
||||
* bleibt das App-Standarddesign (bg-blue) unverändert. */
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
|
||||
const fullName = [trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
|
||||
const initials =
|
||||
|
|
@ -146,6 +133,9 @@ export const BookingDialog = ({
|
|||
// liegt beim Aufrufer.
|
||||
canCancel = false,
|
||||
onRequestCancel,
|
||||
// Nur die öffentliche Buchungsansicht übergibt die Gebühr. Admin-Ansicht
|
||||
// und "Meine Kurse" zeigen weiter den reinen Kurspreis.
|
||||
serviceFeeCents = 0,
|
||||
}: {
|
||||
course: PublicCourse;
|
||||
business?: BusinessInfo;
|
||||
|
|
@ -155,6 +145,7 @@ export const BookingDialog = ({
|
|||
accentColor?: string;
|
||||
canCancel?: boolean;
|
||||
onRequestCancel?: () => void;
|
||||
serviceFeeCents?: number;
|
||||
}) => {
|
||||
const accentButtonStyle = accentColor
|
||||
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||
|
|
@ -188,10 +179,11 @@ export const BookingDialog = ({
|
|||
year: "numeric",
|
||||
});
|
||||
|
||||
const formattedPrice = course.price.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
// Preisangabenverordnung: Der Kunde muss vor dem Checkout den Endpreis
|
||||
// sehen, also inklusive der Servicegebühr - sonst taucht sie erst bei
|
||||
// Stripe auf.
|
||||
const serviceFee = serviceFeeCents / 100;
|
||||
const formattedPrice = formatEuro(course.price + serviceFee);
|
||||
|
||||
// Booking-Status nur laden wenn keine Admin-Ansicht
|
||||
useEffect(() => {
|
||||
|
|
@ -205,6 +197,9 @@ export const BookingDialog = ({
|
|||
if (!active) return;
|
||||
setIsBooked(result.success ? result.isBooked : false);
|
||||
setIsOnWaitlist(result.isOnWaitlist);
|
||||
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
|
||||
// gescheiterten Buchungsversuch.
|
||||
if (result.isLoggedIn === false) setShowGuestForm(true);
|
||||
} catch {
|
||||
if (!active) return;
|
||||
setIsBooked(false);
|
||||
|
|
@ -333,9 +328,11 @@ export const BookingDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* Am Handy als Panel, das unten andockt - Buchen-Button und Formular
|
||||
liegen so im Daumenbereich. Ab sm bleibt es der mittige Dialog. */}
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col"
|
||||
className="sm:max-w-3xl! w-[95vw] max-w-[calc(100%-1rem)] p-0 overflow-hidden gap-0 max-h-[90vh] sm:max-h-[85vh] flex flex-col max-sm:top-auto max-sm:bottom-0 max-sm:w-full max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none max-sm:rounded-t-2xl max-sm:border-x-0 max-sm:border-b-0 max-sm:max-h-[92dvh] max-sm:pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{course.name}</DialogTitle>
|
||||
|
|
@ -466,7 +463,7 @@ export const BookingDialog = ({
|
|||
{/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe
|
||||
validateDiscountCode) - im Gast-Formular macht das Feld daher
|
||||
keinen Sinn und würde nur einen ungültigen Code suggerieren. */}
|
||||
{!isAdminView && !isFull && !isBooked && !showGuestForm && (
|
||||
{!isAdminView && !isCheckingStatus && !isFull && !isBooked && !showGuestForm && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -489,22 +486,26 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{appliedDiscount ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400 font-normal mr-2">
|
||||
{formattedPrice}
|
||||
</span>
|
||||
Preis:{" "}
|
||||
{appliedDiscount.discountedPrice.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
`Preis: ${formattedPrice}`
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{appliedDiscount ? (
|
||||
<>
|
||||
<span className="line-through text-gray-400 font-normal mr-2">
|
||||
{formattedPrice}
|
||||
</span>
|
||||
Preis: {formatEuro(appliedDiscount.discountedPrice + serviceFee)}
|
||||
</>
|
||||
) : (
|
||||
`Preis: ${formattedPrice}`
|
||||
)}
|
||||
</span>
|
||||
{serviceFee > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatEuro(appliedDiscount ? appliedDiscount.discountedPrice : course.price)}{" "}
|
||||
Kurs + {formatEuro(serviceFee)} Servicegebühr
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
{/* Admin-Ansicht: nur Status-Badge + Schließen */}
|
||||
{isAdminView ? (
|
||||
|
|
@ -565,7 +566,9 @@ export const BookingDialog = ({
|
|||
{isJoiningWaitlist ? "Wird eingetragen…" : "Auf Warteliste eintragen"}
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
) : showGuestForm ? null : (
|
||||
// Gäste buchen über das Formular darunter - ein zweiter
|
||||
// "Buchen"-Button daneben wäre nur verwirrend.
|
||||
<Button
|
||||
onClick={() => handleBooking()}
|
||||
disabled={isSubmitting}
|
||||
|
|
@ -597,17 +600,18 @@ export const BookingDialog = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Gast-Formular fürs reguläre Buchen: erscheint nach erstem 401
|
||||
(nicht eingeloggt). Bei vollem Kurs entfällt es zugunsten des
|
||||
Login-Hinweises oben. */}
|
||||
{/* Gast-Formular: erscheint sofort, wenn niemand angemeldet ist
|
||||
(sonst nach einem "nicht angemeldet" vom Checkout). Bei vollem
|
||||
Kurs entfällt es zugunsten des Login-Hinweises oben. */}
|
||||
{!isAdminView && !isFull && showGuestForm && !isBooked && (
|
||||
<div className="border-t border-gray-100 pt-3 flex flex-col gap-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Ohne Konto buchen – oder{" "}
|
||||
Ohne Konto buchen – die Bestätigung kommt per E-Mail, Ihre Rechnungsadresse geben
|
||||
Sie im nächsten Schritt bei der Zahlung an. Schon Kunde?{" "}
|
||||
<Link href="/login" className="underline text-gray-700 hover:text-gray-900">
|
||||
anmelden
|
||||
Melden Sie sich an
|
||||
</Link>
|
||||
.
|
||||
, dann können Sie auch einen Rabattcode einlösen.
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -639,139 +643,3 @@ export const BookingDialog = ({
|
|||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const PublicCourseCard = ({
|
||||
course,
|
||||
business,
|
||||
defaultOpen = false,
|
||||
accentColor,
|
||||
}: PublicCourseCardProps) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(defaultOpen);
|
||||
|
||||
const cardSafeCurrent = Math.max(0, course.currentParticipants);
|
||||
const cardSafeMax = Math.max(0, course.maxParticipants);
|
||||
const isFull = computeIsFull(course);
|
||||
const currentLevel = levelConfig[course.level] ?? levelConfig.any;
|
||||
|
||||
const formattedDate = new Date(course.date).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const formattedPrice = course.price.toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
|
||||
const fillPercent = cardSafeMax > 0 ? Math.min(100, (cardSafeCurrent / cardSafeMax) * 100) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="border border-gray-200 bg-white rounded-xl shadow-sm hover:shadow-md transition flex flex-col cursor-pointer overflow-hidden"
|
||||
>
|
||||
{course.coverImage && (
|
||||
<div className="relative w-full h-36 shrink-0">
|
||||
<Image src={course.coverImage} alt={course.name} fill className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-500 text-sm">{formattedDate}</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{course.timeFrom} – {course.timeTo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{course.trainerProfiles && course.trainerProfiles.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{course.trainerProfiles.map((trainer) => {
|
||||
const fullName =
|
||||
[trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
|
||||
return (
|
||||
<Tooltip key={trainer.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TrainerAvatar trainer={trainer} size="sm" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{fullName}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold text-gray-900">{course.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-1 rounded-lg ${currentLevel.bg} ${currentLevel.text}`}
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
<span className="text-lg font-semibold">{formattedPrice}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={isFull ? "text-red-600 font-semibold text-sm" : "text-gray-700 text-sm"}
|
||||
>
|
||||
{course.currentParticipants}/{course.maxParticipants} Teilnehmer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-2 bg-white/40 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${isFull ? "bg-red-500" : accentColor ? "" : "bg-blue-500"}`}
|
||||
style={{
|
||||
width: `${fillPercent}%`,
|
||||
backgroundColor: !isFull && accentColor ? accentColor : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isFull ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Warteliste
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
style={
|
||||
accentColor
|
||||
? { backgroundColor: accentColor, color: getContrastTextColor(accentColor) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Jetzt anmelden
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookingDialog
|
||||
course={course}
|
||||
business={business}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicCourseCard;
|
||||
119
src/modules/courses/ui/components/CalendarEventBlock.tsx
Normal file
119
src/modules/courses/ui/components/CalendarEventBlock.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { BookingDialog, type BusinessInfo, type PublicCourse } from "./BookingDialog";
|
||||
import TrainerAvatarStack, { trainerName } from "./TrainerAvatarStack";
|
||||
import type { SportColor } from "../../lib/sport-colors";
|
||||
import { availabilityOf, formatEuro, freeSpots } from "./course-schedule-shared";
|
||||
|
||||
interface CalendarEventBlockProps {
|
||||
course: PublicCourse;
|
||||
color: SportColor;
|
||||
/** Lage im Zeitraster: top, height, left, width. */
|
||||
position: CSSProperties;
|
||||
/** Höhe in px - kurze Kurse zeigen weniger Text. */
|
||||
height: number;
|
||||
/** Für Screenreader, z.B. "Morgen · Freitag, 2. Oktober". */
|
||||
dayLabel: string;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
serviceFeeCents: number;
|
||||
}
|
||||
|
||||
// Ausgebuchte Kurse bleiben klickbar (Warteliste), wirken aber zurückgenommen.
|
||||
const FULL_PATTERN =
|
||||
"repeating-linear-gradient(135deg, transparent 0 6px, rgb(0 0 0 / 0.045) 6px 12px)";
|
||||
// Ab dieser Höhe (etwa 50 Minuten) steht der Block untereinander: Zeit, Name,
|
||||
// Trainer mit Preis. Darunter alles in einer Zeile, das Bild rechts.
|
||||
const FULL_LAYOUT_MIN_HEIGHT = 66;
|
||||
|
||||
/** Ein Kurs als Block im Wochenraster - an seiner Uhrzeit, so hoch wie er dauert. */
|
||||
export default function CalendarEventBlock({
|
||||
course,
|
||||
color,
|
||||
position,
|
||||
height,
|
||||
dayLabel,
|
||||
business,
|
||||
accentColor,
|
||||
serviceFeeCents,
|
||||
}: CalendarEventBlockProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const availability = availabilityOf(course);
|
||||
const free = freeSpots(course);
|
||||
const isFull = free === 0;
|
||||
const price = formatEuro(course.price + serviceFeeCents / 100);
|
||||
const trainers = course.trainerProfiles ?? [];
|
||||
const trainerNames = trainers.map(trainerName).join(", ");
|
||||
const isTall = height >= FULL_LAYOUT_MIN_HEIGHT;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={[
|
||||
course.name,
|
||||
dayLabel,
|
||||
`${course.timeFrom} bis ${course.timeTo}`,
|
||||
trainerNames && `mit ${trainerNames}`,
|
||||
availability.label,
|
||||
price,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
className="absolute flex overflow-hidden rounded-xl border-l-4 px-2.5 py-1.5 text-left shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-[translate,scale,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:z-10 hover:-translate-y-px hover:shadow-md focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 active:scale-[0.97]"
|
||||
style={{
|
||||
...position,
|
||||
backgroundColor: color.bg,
|
||||
borderLeftColor: color.border,
|
||||
color: color.text,
|
||||
...(isFull ? { backgroundImage: FULL_PATTERN } : {}),
|
||||
}}
|
||||
>
|
||||
{isTall ? (
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-baseline justify-between gap-1 text-[11px] font-medium tabular-nums">
|
||||
<span className="truncate opacity-75">
|
||||
{course.timeFrom}–{course.timeTo}
|
||||
</span>
|
||||
<span className={`shrink-0 ${free <= 3 ? "font-semibold" : "opacity-75"}`}>
|
||||
{isFull ? "Voll" : `${free} frei`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 text-[13px] font-semibold leading-tight">
|
||||
{course.name}
|
||||
</span>
|
||||
<span className="mt-auto flex items-center gap-1.5 pt-1">
|
||||
<TrainerAvatarStack trainers={trainers} size={22} ringColor={color.bg} max={2} />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] opacity-80">
|
||||
{trainers[0]?.firstName ?? ""}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] font-semibold tabular-nums">{price}</span>
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[11px] font-medium tabular-nums opacity-75">
|
||||
{course.timeFrom}–{course.timeTo}
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-semibold leading-tight">
|
||||
{course.name}
|
||||
</span>
|
||||
</span>
|
||||
<TrainerAvatarStack trainers={trainers} size={20} ringColor={color.bg} max={1} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<BookingDialog
|
||||
course={course}
|
||||
business={business}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
accentColor={accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
182
src/modules/courses/ui/components/CourseCardGrid.tsx
Normal file
182
src/modules/courses/ui/components/CourseCardGrid.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { BookingDialog, type BusinessInfo, type PublicCourse } from "./BookingDialog";
|
||||
import TrainerAvatarStack, { trainerName } from "./TrainerAvatarStack";
|
||||
import { colorForSports, type SportColor } from "../../lib/sport-colors";
|
||||
import { COURSE_LEVELS } from "../../lib/course-levels";
|
||||
import { formatDayHeading } from "../../lib/course-days";
|
||||
import {
|
||||
availabilityOf,
|
||||
durationLabel,
|
||||
formatEuro,
|
||||
freeSpots,
|
||||
sportLabel,
|
||||
} from "./course-schedule-shared";
|
||||
|
||||
interface CourseCardGridProps {
|
||||
days: string[];
|
||||
todayKey: string;
|
||||
coursesByDay: Map<string, PublicCourse[]>;
|
||||
colors: Map<string, SportColor>;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
accentTextColor: string;
|
||||
serviceFeeCents: number;
|
||||
}
|
||||
|
||||
/** Die Kurse der Woche als Karten, nach Tagen gruppiert. */
|
||||
export default function CourseCardGrid({
|
||||
days,
|
||||
todayKey,
|
||||
coursesByDay,
|
||||
colors,
|
||||
business,
|
||||
accentColor,
|
||||
accentTextColor,
|
||||
serviceFeeCents,
|
||||
}: CourseCardGridProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{days.map((key) => {
|
||||
const courses = coursesByDay.get(key) ?? [];
|
||||
if (courses.length === 0) return null;
|
||||
const heading = formatDayHeading(key, todayKey);
|
||||
return (
|
||||
<section key={key} aria-label={heading}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-700">{heading}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{courses.map((course) => (
|
||||
<CourseCard
|
||||
key={course.id}
|
||||
course={course}
|
||||
color={colorForSports(course.sport, colors)}
|
||||
business={business}
|
||||
accentColor={accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CourseCard({
|
||||
course,
|
||||
color,
|
||||
business,
|
||||
accentColor,
|
||||
accentTextColor,
|
||||
serviceFeeCents,
|
||||
}: {
|
||||
course: PublicCourse;
|
||||
color: SportColor;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
accentTextColor: string;
|
||||
serviceFeeCents: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const free = freeSpots(course);
|
||||
const isFull = free === 0;
|
||||
const availability = availabilityOf(course);
|
||||
const duration = durationLabel(course);
|
||||
const trainers = course.trainerProfiles ?? [];
|
||||
const level = COURSE_LEVELS[course.level]?.label;
|
||||
|
||||
return (
|
||||
<article className="group relative flex flex-col overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[translate,scale,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg active:scale-[0.99]">
|
||||
{/* Titelbild, sonst eine weiche Fläche in der Farbe der Kursart */}
|
||||
<div className="relative h-32 overflow-hidden" style={{ backgroundColor: color.bg }}>
|
||||
{course.coverImage ? (
|
||||
<Image
|
||||
src={course.coverImage}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className="object-cover transition-[scale] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-[1.03]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 85% 10%, ${color.dot}40, transparent 55%), radial-gradient(circle at 0% 120%, ${color.dot}2e, transparent 50%)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="absolute left-3 top-3 rounded-full bg-white/90 px-2.5 py-1 text-xs font-semibold tabular-nums text-gray-900 shadow-sm backdrop-blur">
|
||||
{course.timeFrom}–{course.timeTo}
|
||||
{duration && <span className="font-normal text-gray-500"> · {duration}</span>}
|
||||
</span>
|
||||
{free <= 3 && (
|
||||
<span
|
||||
className={`absolute right-3 top-3 rounded-full px-2.5 py-1 text-xs font-semibold shadow-sm ${
|
||||
isFull ? "bg-red-600 text-white" : "bg-amber-400 text-amber-950"
|
||||
}`}
|
||||
>
|
||||
{isFull ? "Ausgebucht" : `Nur noch ${free}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<p className="flex items-center gap-1.5 text-xs font-medium" style={{ color: color.text }}>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: color.dot }}
|
||||
/>
|
||||
{[course.sport[0] && sportLabel(course.sport[0]), level].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
<h4 className="mt-1 text-lg font-semibold leading-snug text-gray-900">{course.name}</h4>
|
||||
|
||||
{trainers.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2.5">
|
||||
<TrainerAvatarStack trainers={trainers} size={32} />
|
||||
<span className="min-w-0 truncate text-sm text-gray-600">
|
||||
mit {trainers.map(trainerName).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{course.roomName && <p className="mt-2 text-xs text-gray-500">{course.roomName}</p>}
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold tabular-nums text-gray-900">
|
||||
{formatEuro(course.price + serviceFeeCents / 100)}
|
||||
</p>
|
||||
<p className={`text-xs ${availability.className}`}>{availability.label}</p>
|
||||
</div>
|
||||
{/* Die ganze Karte ist klickbar - der Button spannt sich darüber. Kein
|
||||
scale auf dem Button: das würde die Fläche beim Drücken schrumpfen. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`${course.name} um ${course.timeFrom} ${isFull ? "– Warteliste" : "buchen"}`}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-[filter] duration-150 after:absolute after:inset-0 after:content-[''] hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 ${
|
||||
isFull ? "border border-gray-300 bg-white text-gray-800" : "shadow-sm"
|
||||
}`}
|
||||
style={isFull ? undefined : { backgroundColor: accentColor, color: accentTextColor }}
|
||||
>
|
||||
{isFull ? "Warteliste" : "Buchen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookingDialog
|
||||
course={course}
|
||||
business={business}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
accentColor={accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
117
src/modules/courses/ui/components/DayTimeline.tsx
Normal file
117
src/modules/courses/ui/components/DayTimeline.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { BookingDialog, type BusinessInfo, type PublicCourse } from "./BookingDialog";
|
||||
import TrainerAvatarStack from "./TrainerAvatarStack";
|
||||
import { colorForSports, type SportColor } from "../../lib/sport-colors";
|
||||
import {
|
||||
availabilityOf,
|
||||
courseMetaLine,
|
||||
durationLabel,
|
||||
formatEuro,
|
||||
} from "./course-schedule-shared";
|
||||
|
||||
interface DayTimelineProps {
|
||||
courses: PublicCourse[];
|
||||
colors: Map<string, SportColor>;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
serviceFeeCents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kurse eines Tages als Zeitleiste fürs Handy: Uhrzeit links, ein
|
||||
* farbiger Punkt pro Kurs auf einer durchgehenden Linie, rechts die Karte.
|
||||
*/
|
||||
export default function DayTimeline({
|
||||
courses,
|
||||
colors,
|
||||
business,
|
||||
accentColor,
|
||||
serviceFeeCents,
|
||||
}: DayTimelineProps) {
|
||||
return (
|
||||
<ol>
|
||||
{courses.map((course) => (
|
||||
<TimelineItem
|
||||
key={course.id}
|
||||
course={course}
|
||||
color={colorForSports(course.sport, colors)}
|
||||
business={business}
|
||||
accentColor={accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
course,
|
||||
color,
|
||||
business,
|
||||
accentColor,
|
||||
serviceFeeCents,
|
||||
}: {
|
||||
course: PublicCourse;
|
||||
color: SportColor;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
serviceFeeCents: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const availability = availabilityOf(course);
|
||||
const meta = courseMetaLine(course);
|
||||
const duration = durationLabel(course);
|
||||
|
||||
return (
|
||||
<li className="group grid grid-cols-[3rem_0.75rem_1fr] gap-x-3 pb-3 last:pb-0">
|
||||
<div className="pt-3.5 text-right">
|
||||
<div className="text-sm font-semibold tabular-nums text-gray-900">{course.timeFrom}</div>
|
||||
{duration && <div className="text-[11px] text-gray-400">{duration}</div>}
|
||||
</div>
|
||||
|
||||
{/* Linie über und unter dem Punkt - am ersten und letzten Kurs offen */}
|
||||
<div aria-hidden className="relative flex justify-center">
|
||||
<span className="absolute top-0 h-5 w-px bg-gray-200 group-first:hidden" />
|
||||
<span className="absolute -bottom-3 top-5 w-px bg-gray-200 group-last:hidden" />
|
||||
<span
|
||||
className="relative mt-[1.1rem] size-3 rounded-full ring-4 ring-white"
|
||||
style={{ backgroundColor: color.dot }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex min-w-0 items-center gap-3 rounded-2xl border border-gray-200/80 bg-white p-3.5 text-left shadow-sm transition-[scale,box-shadow] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 active:scale-[0.98]"
|
||||
style={{ borderLeft: `4px solid ${color.border}` }}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-gray-900">{course.name}</span>
|
||||
{meta && <span className="block truncate text-xs text-gray-500">{meta}</span>}
|
||||
<span className={`mt-1.5 block text-xs ${availability.className}`}>
|
||||
{availability.label}
|
||||
</span>
|
||||
</span>
|
||||
<TrainerAvatarStack trainers={course.trainerProfiles ?? []} size={36} max={2} />
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span className="font-semibold tabular-nums text-gray-900">
|
||||
{formatEuro(course.price + serviceFeeCents / 100)}
|
||||
</span>
|
||||
<ChevronRight size={16} className="text-gray-400" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<BookingDialog
|
||||
course={course}
|
||||
business={business}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
accentColor={accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
81
src/modules/courses/ui/components/TrainerAvatarStack.tsx
Normal file
81
src/modules/courses/ui/components/TrainerAvatarStack.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import Image from "next/image";
|
||||
import type { TrainerProfile } from "./BookingDialog";
|
||||
|
||||
export function trainerName(trainer: TrainerProfile): string {
|
||||
return [trainer.firstName, trainer.lastName].filter(Boolean).join(" ") || "Trainer";
|
||||
}
|
||||
|
||||
function initials(trainer: TrainerProfile): string {
|
||||
return (
|
||||
[trainer.firstName?.[0], trainer.lastName?.[0]].filter(Boolean).join("").toUpperCase() || "T"
|
||||
);
|
||||
}
|
||||
|
||||
interface TrainerAvatarStackProps {
|
||||
trainers: TrainerProfile[];
|
||||
/** Durchmesser in px. */
|
||||
size: number;
|
||||
/** Ring in der Hintergrundfarbe trennt überlappende Bilder voneinander. */
|
||||
ringColor?: string;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profilbilder der Trainer, leicht überlappend. Rein dekorativ - die Namen
|
||||
* stehen daneben bzw. im Label des umgebenden Buttons.
|
||||
*/
|
||||
export default function TrainerAvatarStack({
|
||||
trainers,
|
||||
size,
|
||||
ringColor = "#ffffff",
|
||||
max = 3,
|
||||
}: TrainerAvatarStackProps) {
|
||||
if (trainers.length === 0) return null;
|
||||
const shown = trainers.slice(0, max);
|
||||
const rest = trainers.length - shown.length;
|
||||
const bubble = "relative flex shrink-0 items-center justify-center overflow-hidden rounded-full";
|
||||
|
||||
return (
|
||||
<span aria-hidden className="flex shrink-0 items-center">
|
||||
{shown.map((trainer, i) => (
|
||||
<span
|
||||
key={trainer.id}
|
||||
className={`${bubble} bg-gray-200 font-semibold text-gray-600`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: i === 0 ? 0 : -Math.round(size * 0.3),
|
||||
boxShadow: `0 0 0 2px ${ringColor}`,
|
||||
fontSize: Math.round(size * 0.38),
|
||||
}}
|
||||
>
|
||||
{trainer.pbSrc ? (
|
||||
<Image
|
||||
src={trainer.pbSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes={`${size * 2}px`}
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials(trainer)
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{rest > 0 && (
|
||||
<span
|
||||
className={`${bubble} bg-gray-100 font-semibold text-gray-600`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: -Math.round(size * 0.3),
|
||||
boxShadow: `0 0 0 2px ${ringColor}`,
|
||||
fontSize: Math.round(size * 0.36),
|
||||
}}
|
||||
>
|
||||
+{rest}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,11 +8,8 @@ import type { UserBooking } from "../../actions/booking-actions";
|
|||
import { cancelUserBooking } from "../../actions/booking-actions";
|
||||
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
|
||||
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
|
||||
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||
import type {
|
||||
PublicCourse,
|
||||
BusinessInfo,
|
||||
} from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";
|
||||
import type { PublicCourse, BusinessInfo } from "@/src/modules/courses/ui/components/BookingDialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const LEVEL_LABELS: Record<string, { label: string; bg: string; text: string }> = {
|
||||
|
|
|
|||
176
src/modules/courses/ui/components/WeekTimeGrid.tsx
Normal file
176
src/modules/courses/ui/components/WeekTimeGrid.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import type { BusinessInfo, PublicCourse } from "./BookingDialog";
|
||||
import CalendarEventBlock from "./CalendarEventBlock";
|
||||
import { colorForSports, type SportColor } from "../../lib/sport-colors";
|
||||
import { layoutDay, timeToMinutes, visibleHourRange } from "../../lib/calendar-layout";
|
||||
import { BERLIN, formatDayHeading, keyToNoon } from "../../lib/course-days";
|
||||
|
||||
// Großzügig, damit auch ein 60-Minuten-Kurs Zeit, Name, Trainerbild und
|
||||
// Preis zeigen kann.
|
||||
const HOUR_PX = 80;
|
||||
const MINUTE_PX = HOUR_PX / 60;
|
||||
// Auch ein 15-Minuten-Kurs bleibt so lesbar und gut antippbar.
|
||||
const MIN_BLOCK_PX = 36;
|
||||
// Rot wie in Apple- und Google-Kalender - "jetzt" muss auf jedem Hintergrund
|
||||
// sofort auffallen, unabhängig von der Studiofarbe.
|
||||
const NOW_COLOR = "#ef4444";
|
||||
|
||||
interface WeekTimeGridProps {
|
||||
days: string[];
|
||||
todayKey: string;
|
||||
/** Aktuelle Berliner Uhrzeit "HH:MM", null vor der Hydration. */
|
||||
nowTime: string | null;
|
||||
coursesByDay: Map<string, PublicCourse[]>;
|
||||
colors: Map<string, SportColor>;
|
||||
business: BusinessInfo;
|
||||
accentColor: string;
|
||||
accentTextColor: string;
|
||||
serviceFeeCents: number;
|
||||
}
|
||||
|
||||
function weekdayShort(key: string) {
|
||||
return keyToNoon(key).toLocaleDateString("de-DE", { timeZone: BERLIN, weekday: "short" });
|
||||
}
|
||||
|
||||
function dayOfMonth(key: string) {
|
||||
return keyToNoon(key).toLocaleDateString("de-DE", { timeZone: BERLIN, day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wochenansicht mit Zeitachse: sieben Spalten, Kurse als Blöcke an ihrer
|
||||
* Uhrzeit. Der Stundenbereich passt sich an die Kurse der Woche an, damit kein
|
||||
* leerer Vormittag Platz frisst.
|
||||
*/
|
||||
export default function WeekTimeGrid({
|
||||
days,
|
||||
todayKey,
|
||||
nowTime,
|
||||
coursesByDay,
|
||||
colors,
|
||||
business,
|
||||
accentColor,
|
||||
accentTextColor,
|
||||
serviceFeeCents,
|
||||
}: WeekTimeGridProps) {
|
||||
const { startHour, endHour } = visibleHourRange(
|
||||
days.flatMap((key) => coursesByDay.get(key) ?? [])
|
||||
);
|
||||
const hours = endHour - startHour;
|
||||
const offset = startHour * 60;
|
||||
const nowMinutes = nowTime ? timeToMinutes(nowTime) : null;
|
||||
const columns = "grid grid-cols-[3.5rem_repeat(7,minmax(0,1fr))]";
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200/80">
|
||||
{/* Wochentage */}
|
||||
<div className={`${columns} border-b border-gray-100 bg-gray-50/70`}>
|
||||
<div />
|
||||
{days.map((key) => {
|
||||
const isToday = key === todayKey;
|
||||
const isPast = key < todayKey;
|
||||
return (
|
||||
<div key={key} className="py-3 text-center">
|
||||
<div
|
||||
className={`text-[11px] font-medium uppercase tracking-wider ${isPast ? "text-gray-300" : "text-gray-500"}`}
|
||||
>
|
||||
{weekdayShort(key)}
|
||||
</div>
|
||||
<div
|
||||
className={`mx-auto mt-1 flex size-9 items-center justify-center rounded-full text-lg font-semibold tabular-nums ${isPast ? "text-gray-300" : "text-gray-900"}`}
|
||||
style={
|
||||
isToday ? { backgroundColor: accentColor, color: accentTextColor } : undefined
|
||||
}
|
||||
>
|
||||
{dayOfMonth(key)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Raster */}
|
||||
<div className={`relative ${columns}`} style={{ height: hours * HOUR_PX }}>
|
||||
{Array.from({ length: hours }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 border-t border-gray-100"
|
||||
style={{ top: i * HOUR_PX }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div aria-hidden className="relative">
|
||||
{Array.from({ length: hours }, (_, i) =>
|
||||
i === 0 ? null : (
|
||||
<span
|
||||
key={i}
|
||||
className="absolute right-2 -translate-y-1/2 text-[11px] tabular-nums text-gray-400"
|
||||
style={{ top: i * HOUR_PX }}
|
||||
>
|
||||
{String(startHour + i).padStart(2, "0")}:00
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{days.map((key) => {
|
||||
const isToday = key === todayKey;
|
||||
const isPast = key < todayKey;
|
||||
const placed = layoutDay(coursesByDay.get(key) ?? []);
|
||||
const showNow =
|
||||
isToday && nowMinutes !== null && nowMinutes >= offset && nowMinutes <= endHour * 60;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative border-l border-gray-100"
|
||||
style={
|
||||
isToday
|
||||
? { backgroundColor: `color-mix(in srgb, ${accentColor} 7%, transparent)` }
|
||||
: isPast
|
||||
? { backgroundColor: "rgb(249 250 251 / 0.8)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{placed.map((item) => {
|
||||
const height = Math.max(MIN_BLOCK_PX, (item.end - item.start) * MINUTE_PX - 2);
|
||||
const width = 100 / item.laneCount;
|
||||
return (
|
||||
<CalendarEventBlock
|
||||
key={item.course.id}
|
||||
course={item.course}
|
||||
color={colorForSports(item.course.sport, colors)}
|
||||
height={height}
|
||||
dayLabel={formatDayHeading(key, todayKey)}
|
||||
position={{
|
||||
top: (item.start - offset) * MINUTE_PX + 1,
|
||||
height,
|
||||
left: `calc(${item.lane * width}% + 3px)`,
|
||||
width: `calc(${width}% - 6px)`,
|
||||
}}
|
||||
business={business}
|
||||
accentColor={accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{showNow && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 z-20 flex -translate-y-1/2 items-center"
|
||||
style={{ top: (nowMinutes - offset) * MINUTE_PX }}
|
||||
>
|
||||
<span
|
||||
className="-ml-1 size-2.5 rounded-full"
|
||||
style={{ backgroundColor: NOW_COLOR }}
|
||||
/>
|
||||
<span className="h-0.5 flex-1" style={{ backgroundColor: NOW_COLOR }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/modules/courses/ui/components/course-schedule-shared.ts
Normal file
44
src/modules/courses/ui/components/course-schedule-shared.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { PublicCourse } from "./BookingDialog";
|
||||
import { COURSE_LEVELS } from "../../lib/course-levels";
|
||||
import { durationMinutes } from "../../lib/course-days";
|
||||
|
||||
export const formatEuro = (euros: number) =>
|
||||
euros.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
|
||||
export function freeSpots(course: Pick<PublicCourse, "currentParticipants" | "maxParticipants">) {
|
||||
return Math.max(0, course.maxParticipants - course.currentParticipants);
|
||||
}
|
||||
|
||||
/** "Ausgebucht", "Nur noch 2 Plätze" oder "8 Plätze frei" - mit passender Farbe. */
|
||||
export function availabilityOf(course: PublicCourse): { label: string; className: string } {
|
||||
const free = freeSpots(course);
|
||||
if (free === 0) return { label: "Ausgebucht", className: "text-red-600 font-medium" };
|
||||
if (free <= 3) {
|
||||
return {
|
||||
label: `Nur noch ${free} ${free === 1 ? "Platz" : "Plätze"}`,
|
||||
className: "text-amber-600 font-medium",
|
||||
};
|
||||
}
|
||||
return { label: `${free} Plätze frei`, className: "text-gray-500" };
|
||||
}
|
||||
|
||||
/** Level, Trainer und Raum als eine Zeile, z.B. "Anfänger · mit Anna Muster · Raum 2". */
|
||||
export function courseMetaLine(course: PublicCourse): string {
|
||||
const trainers = (course.trainerProfiles ?? [])
|
||||
.map((t) => [t.firstName, t.lastName].filter(Boolean).join(" "))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return [COURSE_LEVELS[course.level]?.label, trainers ? `mit ${trainers}` : null, course.roomName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** Kursarten sind klein geschrieben gespeichert ("yoga") - für die Anzeige groß. */
|
||||
export function sportLabel(sport: string): string {
|
||||
return sport.charAt(0).toUpperCase() + sport.slice(1);
|
||||
}
|
||||
|
||||
export function durationLabel(course: PublicCourse): string | null {
|
||||
const minutes = durationMinutes(course.timeFrom, course.timeTo);
|
||||
return minutes ? `${minutes} Min` : null;
|
||||
}
|
||||
|
|
@ -1,13 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition, useMemo } from "react";
|
||||
import { useMemo, useState, useSyncExternalStore, useTransition, type CSSProperties } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import PublicCourseCard from "../components/PublicCourseCard";
|
||||
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
|
||||
import { Calendar } from "@/src/components/ui/calendar";
|
||||
import { de } from "date-fns/locale";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, LayoutGrid, MapPin } from "lucide-react";
|
||||
import { getContrastTextColor } from "@/src/modules/settings/lib/contrast";
|
||||
import { BookingDialog, type PublicCourse } from "../components/BookingDialog";
|
||||
import WeekTimeGrid from "../components/WeekTimeGrid";
|
||||
import DayTimeline from "../components/DayTimeline";
|
||||
import CourseCardGrid from "../components/CourseCardGrid";
|
||||
import { sportLabel } from "../components/course-schedule-shared";
|
||||
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
|
||||
import { sportColorMap } from "../../lib/sport-colors";
|
||||
import { isoWeekNumber } from "../../lib/calendar-layout";
|
||||
import {
|
||||
BERLIN,
|
||||
addDaysToKey,
|
||||
berlinTimeNow,
|
||||
dayKey,
|
||||
formatDayHeading,
|
||||
formatShortDay,
|
||||
keyToNoon,
|
||||
startOfWeekKey,
|
||||
weekDayKeys,
|
||||
} from "../../lib/course-days";
|
||||
|
||||
interface Business {
|
||||
id: string;
|
||||
|
|
@ -21,267 +37,508 @@ interface Business {
|
|||
accentColor: string;
|
||||
}
|
||||
|
||||
interface TrainerProfile {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
pbSrc: string | null;
|
||||
}
|
||||
|
||||
interface PublicCourse {
|
||||
id: string;
|
||||
name: string;
|
||||
sport: string[];
|
||||
level: string;
|
||||
date: Date;
|
||||
timeFrom: string;
|
||||
timeTo: string;
|
||||
maxParticipants: number;
|
||||
price: number;
|
||||
currentParticipants: number;
|
||||
description?: string;
|
||||
room?: string;
|
||||
trainerProfiles?: TrainerProfile[];
|
||||
}
|
||||
|
||||
interface BusinessPublicViewProps {
|
||||
business: Business;
|
||||
initialCourses: PublicCourse[];
|
||||
initialWindow: { from: Date; to: Date };
|
||||
/** Servicegebühr in Cents - Kunden sehen den Endpreis schon vor dem Checkout. */
|
||||
serviceFeeCents: number;
|
||||
/** Stornoregel des Studios in einem Satz, siehe describeCancellationPolicy. */
|
||||
cancellationSummary: string;
|
||||
}
|
||||
|
||||
const TODAY = new Date();
|
||||
TODAY.setHours(0, 0, 0, 0);
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Wie weit beim Blättern über das Geladene hinaus nachgeladen wird.
|
||||
const LOAD_MORE_DAYS = 30;
|
||||
// Starke ease-out-Kurve: der Wochenwechsel reagiert sofort und läuft weich aus.
|
||||
const EASE_OUT = "ease-[cubic-bezier(0.23,1,0.32,1)]";
|
||||
|
||||
type View = "calendar" | "cards";
|
||||
const VIEWS = [
|
||||
{ id: "calendar", label: "Kalender", Icon: CalendarDays },
|
||||
{ id: "cards", label: "Karten", Icon: LayoutGrid },
|
||||
] as const;
|
||||
|
||||
// Die Uhrzeit für die "Jetzt"-Linie: einmal pro Minute neu. Serverseitig und
|
||||
// bei der Hydration null (dann wird nichts ausgeblendet), danach die echte
|
||||
// Berliner Uhrzeit - ohne Abweichung zwischen Server- und Browser-Rendering.
|
||||
function subscribeToMinute(onChange: () => void) {
|
||||
const id = setInterval(onChange, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
|
||||
function byDateAndTime(a: PublicCourse, b: PublicCourse) {
|
||||
return dayKey(a.date).localeCompare(dayKey(b.date)) || a.timeFrom.localeCompare(b.timeFrom);
|
||||
}
|
||||
|
||||
function mergeCourses(existing: PublicCourse[], incoming: PublicCourse[]): PublicCourse[] {
|
||||
const byId = new Map(existing.map((course) => [course.id, course]));
|
||||
for (const course of incoming) byId.set(course.id, course);
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function formatKey(key: string, options: Intl.DateTimeFormatOptions) {
|
||||
return keyToNoon(key).toLocaleDateString("de-DE", { timeZone: BERLIN, ...options });
|
||||
}
|
||||
|
||||
/** "Oktober 2026", über Monats- und Jahresgrenzen "September – Oktober 2026". */
|
||||
function monthLabel(fromKey: string, toKey: string) {
|
||||
const from = formatKey(fromKey, { month: "long", year: "numeric" });
|
||||
const to = formatKey(toKey, { month: "long", year: "numeric" });
|
||||
if (from === to) return from;
|
||||
return fromKey.slice(0, 4) === toKey.slice(0, 4)
|
||||
? `${formatKey(fromKey, { month: "long" })} – ${to}`
|
||||
: `${from} – ${to}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Studio-Seite, wochenweise. Als Kalender am Desktop eine Woche
|
||||
* mit Zeitachse, in der Kurse als Blöcke an ihrer Uhrzeit stehen, am Handy
|
||||
* eine Tagesleiste mit der Zeitleiste des gewählten Tages. Als Karten die
|
||||
* Kurse der Woche nach Tagen gruppiert.
|
||||
*/
|
||||
const BusinessPublicView = ({
|
||||
business,
|
||||
initialCourses,
|
||||
initialWindow,
|
||||
serviceFeeCents,
|
||||
cancellationSummary,
|
||||
}: BusinessPublicViewProps) => {
|
||||
// Deep-Link: ?course=<id> opens the course detail popup and jumps to its date
|
||||
// (e.g. a shared booking link). Resolved once at mount via the lazy initializer below.
|
||||
const searchParams = useSearchParams();
|
||||
const deepLinkCourseId = searchParams.get("course");
|
||||
|
||||
const [todayKey] = useState(() => dayKey(new Date()));
|
||||
const thisWeekKey = startOfWeekKey(todayKey);
|
||||
const nowTime = useSyncExternalStore(subscribeToMinute, berlinTimeNow, () => null);
|
||||
|
||||
const [loadedCourses, setLoadedCourses] = useState<PublicCourse[]>(initialCourses);
|
||||
const [loadedWindow, setLoadedWindow] = useState(initialWindow);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(() => {
|
||||
if (deepLinkCourseId) {
|
||||
const target = initialCourses.find((c) => c.id === deepLinkCourseId);
|
||||
if (target) {
|
||||
const day = new Date(target.date);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
return day;
|
||||
}
|
||||
}
|
||||
return TODAY;
|
||||
});
|
||||
const [activeSport, setActiveSport] = useState<string>("Alle");
|
||||
const [loadedUntil, setLoadedUntil] = useState<Date>(() => new Date(initialWindow.to));
|
||||
const [sport, setSport] = useState("Alle");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// Richtung des letzten Wochenwechsels - die neue Woche gleitet von dort herein.
|
||||
const [direction, setDirection] = useState(0);
|
||||
const [view, setView] = useState<View>("calendar");
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
// Startwoche: diese Woche - außer sie hat keinen Kurs mehr, dann die Woche
|
||||
// des nächsten Kurses. Sonst landet man bei einem Studio, das erst in zwei
|
||||
// Wochen wieder Kurse hat, vor einem leeren Kalender.
|
||||
const [weekKey, setWeekKey] = useState(() => {
|
||||
const nextKey = initialCourses
|
||||
.map((course) => dayKey(course.date))
|
||||
.filter((key) => key >= todayKey)
|
||||
.sort()[0];
|
||||
return nextKey ? startOfWeekKey(nextKey) : thisWeekKey;
|
||||
});
|
||||
// Am Handy der gewählte Tag; null = der erste sinnvolle Tag der Woche.
|
||||
const [selectedDayKey, setSelectedDayKey] = useState<string | null>(null);
|
||||
|
||||
const dayStart = new Date(date);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(date);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
setSelectedDate(dayStart);
|
||||
|
||||
// Within loaded window — client-side filter, no fetch
|
||||
if (dayStart >= loadedWindow.from && dayEnd <= loadedWindow.to) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside window — fetch ±15 day window around chosen date
|
||||
const newFrom = new Date(date);
|
||||
newFrom.setDate(newFrom.getDate() - 15);
|
||||
newFrom.setHours(0, 0, 0, 0);
|
||||
const newTo = new Date(date);
|
||||
newTo.setDate(newTo.getDate() + 15);
|
||||
newTo.setHours(23, 59, 59, 999);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await getPublishedCoursesForBusiness(business.id, {
|
||||
from: newFrom,
|
||||
to: newTo,
|
||||
});
|
||||
if (result.success && result.courses) {
|
||||
setLoadedCourses(result.courses as PublicCourse[]);
|
||||
setLoadedWindow({ from: newFrom, to: newTo });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Derive all distinct sports from loaded courses for tab list
|
||||
const allSports = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
loadedCourses.forEach((c) => c.sport.forEach((s) => set.add(s)));
|
||||
return ["Alle", ...Array.from(set).sort()];
|
||||
}, [loadedCourses]);
|
||||
|
||||
// Reset active sport if it no longer exists after a window refetch
|
||||
const resolvedSport = allSports.includes(activeSport) ? activeSport : "Alle";
|
||||
// Deep-Link (?course=<id>, z.B. ein geteilter Buchungslink) öffnet den
|
||||
// Buchungsdialog direkt.
|
||||
const deepLinkCourse = deepLinkCourseId
|
||||
? initialCourses.find((course) => course.id === deepLinkCourseId)
|
||||
: undefined;
|
||||
const [deepLinkOpen, setDeepLinkOpen] = useState(Boolean(deepLinkCourse));
|
||||
|
||||
const accentTextColor = useMemo(
|
||||
() => getContrastTextColor(business.accentColor),
|
||||
[business.accentColor]
|
||||
);
|
||||
|
||||
const primaryTextColor = useMemo(
|
||||
() => getContrastTextColor(business.primaryColor),
|
||||
[business.primaryColor]
|
||||
);
|
||||
// Sekundärtext (Adresse/Titel) etwas dezenter als die Überschrift, aber im
|
||||
// selben Hell/Dunkel-Lager - sonst könnte z.B. bei dunklem primaryColor eine
|
||||
// "abgeschwächte" graue Sekundärfarbe erneut unlesbar werden.
|
||||
const primaryMutedTextColor =
|
||||
primaryTextColor === "#FFFFFF" ? "rgba(255,255,255,0.75)" : "#4B5563";
|
||||
const onDarkPrimary = primaryTextColor === "#FFFFFF";
|
||||
const primaryMutedTextColor = onDarkPrimary ? "rgba(255,255,255,0.78)" : "#4B5563";
|
||||
const businessInfo = { address: business.address, email: business.email };
|
||||
|
||||
const displayedCourses = useMemo(() => {
|
||||
const dayStart = new Date(selectedDate);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(selectedDate);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
// Buchbar: ab heute, ohne bereits begonnene Kurse.
|
||||
const bookable = useMemo(
|
||||
() =>
|
||||
loadedCourses
|
||||
.filter((course) => {
|
||||
const key = dayKey(course.date);
|
||||
if (key < todayKey) return false;
|
||||
if (key === todayKey && nowTime && course.timeFrom <= nowTime) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(byDateAndTime),
|
||||
[loadedCourses, todayKey, nowTime]
|
||||
);
|
||||
|
||||
return loadedCourses.filter((c) => {
|
||||
const d = new Date(c.date);
|
||||
if (d < dayStart || d > dayEnd) return false;
|
||||
if (resolvedSport !== "Alle" && !c.sport.includes(resolvedSport)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [loadedCourses, selectedDate, resolvedSport]);
|
||||
// Farben über alle geladenen Kurse - dieselbe Kursart behält beim Blättern
|
||||
// ihre Farbe.
|
||||
const sports = useMemo(
|
||||
() => Array.from(new Set(loadedCourses.flatMap((course) => course.sport))).sort(),
|
||||
[loadedCourses]
|
||||
);
|
||||
const colors = useMemo(() => sportColorMap(sports), [sports]);
|
||||
const activeSport = sport === "Alle" || sports.includes(sport) ? sport : "Alle";
|
||||
const visible = bookable.filter(
|
||||
(course) => activeSport === "Alle" || course.sport.includes(activeSport)
|
||||
);
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
const days = weekDayKeys(weekKey);
|
||||
const weekEndKey = days[6];
|
||||
const coursesByDay = new Map<string, PublicCourse[]>(days.map((key) => [key, []]));
|
||||
for (const course of visible) coursesByDay.get(dayKey(course.date))?.push(course);
|
||||
const weekHasCourses = days.some((key) => (coursesByDay.get(key)?.length ?? 0) > 0);
|
||||
const nextCourseAfterWeek = visible.find((course) => dayKey(course.date) > weekEndKey);
|
||||
|
||||
const formattedSelectedDate = selectedDate.toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
// Handy: gewählter Tag, sonst heute (wenn dort etwas stattfindet), sonst der
|
||||
// erste Tag der Woche mit Kursen, sonst der erste noch nicht vergangene Tag.
|
||||
const activeDayKey =
|
||||
(selectedDayKey && days.includes(selectedDayKey) && selectedDayKey) ||
|
||||
(days.includes(todayKey) && (coursesByDay.get(todayKey)?.length ?? 0) > 0 && todayKey) ||
|
||||
days.find((key) => (coursesByDay.get(key)?.length ?? 0) > 0) ||
|
||||
days.find((key) => key >= todayKey) ||
|
||||
days[0];
|
||||
|
||||
const goToWeek = (key: string) => {
|
||||
setDirection(key > weekKey ? 1 : -1);
|
||||
setWeekKey(key);
|
||||
setSelectedDayKey(null);
|
||||
// Liegt die Woche hinter dem Geladenen, nachladen.
|
||||
const weekEnd = new Date(keyToNoon(addDaysToKey(key, 6)).getTime() + DAY_MS);
|
||||
if (weekEnd > loadedUntil) {
|
||||
const from = loadedUntil;
|
||||
const until = new Date(
|
||||
Math.max(weekEnd.getTime(), loadedUntil.getTime() + LOAD_MORE_DAYS * DAY_MS)
|
||||
);
|
||||
startTransition(async () => {
|
||||
const result = await getPublishedCoursesForBusiness(business.id, { from, to: until });
|
||||
if (result.success && result.courses) {
|
||||
setLoadedCourses((current) => mergeCourses(current, result.courses as PublicCourse[]));
|
||||
setLoadedUntil(until);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const rangeLabel = `${formatKey(weekKey, { day: "numeric", month: "short" })} – ${formatKey(
|
||||
weekEndKey,
|
||||
{ day: "numeric", month: "short" }
|
||||
)}`;
|
||||
const showTitle =
|
||||
business.title && business.title.trim().toLowerCase() !== business.name.trim().toLowerCase();
|
||||
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(business.address)}`;
|
||||
const feeLabel = (serviceFeeCents / 100).toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
});
|
||||
const navButton =
|
||||
"flex size-9 items-center justify-center rounded-full text-gray-700 transition-[background-color,scale] duration-150 hover:bg-gray-100 active:scale-95 disabled:pointer-events-none disabled:opacity-30";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="border-b border-gray-200 backdrop-blur-sm"
|
||||
<div className="min-h-screen bg-gray-100/80">
|
||||
{/* Bühne in der Studiofarbe, mit einem weichen Lichtschein der Akzentfarbe */}
|
||||
<header
|
||||
className="relative overflow-hidden"
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-32 -top-40 size-[28rem] rounded-full opacity-30 blur-3xl"
|
||||
style={{ backgroundColor: business.accentColor }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -bottom-48 -left-24 size-96 rounded-full opacity-15 blur-3xl"
|
||||
style={{ backgroundColor: business.accentColor }}
|
||||
/>
|
||||
<div className="relative mx-auto max-w-6xl px-4 pb-24 pt-8 sm:px-6 sm:pt-12">
|
||||
<div className="flex items-center gap-4">
|
||||
{business.logoUrl && (
|
||||
<div className="relative w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-white border border-gray-200">
|
||||
<div className="relative size-14 shrink-0 overflow-hidden rounded-2xl bg-white shadow-lg ring-1 ring-black/5 sm:size-16">
|
||||
<Image
|
||||
src={business.logoUrl}
|
||||
alt={business.name}
|
||||
fill
|
||||
className="object-contain p-1"
|
||||
className="object-contain p-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-4xl font-bold" style={{ color: primaryTextColor }}>
|
||||
{business.name}
|
||||
</h1>
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className="truncate text-3xl font-bold tracking-tight sm:text-4xl"
|
||||
style={{ color: primaryTextColor }}
|
||||
>
|
||||
{business.name}
|
||||
</h1>
|
||||
{showTitle && (
|
||||
<p
|
||||
className="truncate text-sm sm:text-base"
|
||||
style={{ color: primaryMutedTextColor }}
|
||||
>
|
||||
{business.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{business.title && (
|
||||
<p className="text-xl mt-1" style={{ color: primaryMutedTextColor }}>
|
||||
{business.title}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="mt-4 flex flex-col sm:flex-row gap-2 text-sm"
|
||||
<a
|
||||
href={mapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`mt-5 inline-flex max-w-full items-center gap-1.5 rounded-full px-3 py-1.5 text-sm ring-1 backdrop-blur transition-[background-color] duration-150 ${
|
||||
onDarkPrimary
|
||||
? "bg-white/10 ring-white/15 hover:bg-white/20"
|
||||
: "bg-black/5 ring-black/10 hover:bg-black/10"
|
||||
}`}
|
||||
style={{ color: primaryMutedTextColor }}
|
||||
>
|
||||
<span>{business.address}</span>
|
||||
{business.email && (
|
||||
<>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="hover:underline"
|
||||
style={{ color: primaryTextColor }}
|
||||
<MapPin size={14} className="shrink-0" />
|
||||
<span className="truncate">{business.address}</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative mx-auto -mt-16 max-w-6xl px-3 pb-12 sm:px-6">
|
||||
<div className="rounded-3xl bg-white p-4 shadow-xl shadow-gray-900/5 ring-1 ring-gray-900/5 sm:p-6">
|
||||
{/* Monat, Kalenderwoche, Ansicht und Navigation. Am Handy rutscht der
|
||||
Umschalter in eine eigene Zeile. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight text-gray-900 sm:text-2xl">
|
||||
{monthLabel(weekKey, weekEndKey)}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
KW {isoWeekNumber(weekKey)} · {rangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Ansicht"
|
||||
className="order-last col-span-2 flex rounded-full bg-gray-100 p-1 sm:order-none sm:col-span-1"
|
||||
>
|
||||
{VIEWS.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDirection(0);
|
||||
setView(id);
|
||||
}}
|
||||
aria-pressed={view === id}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-[background-color,color,box-shadow] duration-150 ${
|
||||
view === id
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{business.email}
|
||||
</a>
|
||||
<Icon size={16} aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-full border border-gray-200 bg-white p-0.5 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToWeek(addDaysToKey(weekKey, -7))}
|
||||
disabled={weekKey <= thisWeekKey}
|
||||
aria-label="Vorherige Woche"
|
||||
className={navButton}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToWeek(thisWeekKey)}
|
||||
disabled={weekKey === thisWeekKey}
|
||||
className="rounded-full px-3 py-1.5 text-sm font-medium text-gray-700 transition-[background-color,scale] duration-150 hover:bg-gray-100 active:scale-95 disabled:pointer-events-none disabled:text-gray-400"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToWeek(addDaysToKey(weekKey, 7))}
|
||||
aria-label="Nächste Woche"
|
||||
className={navButton}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kursart - zugleich die Farblegende des Kalenders */}
|
||||
{sports.length > 1 && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Kursart"
|
||||
className="-mx-4 mt-4 flex gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:px-0"
|
||||
>
|
||||
{["Alle", ...sports].map((s) => {
|
||||
const active = activeSport === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSport(s)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full px-3.5 py-1.5 text-sm font-medium transition-[background-color,scale] duration-150 active:scale-95 ${
|
||||
active ? "shadow-sm" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
style={
|
||||
active
|
||||
? { backgroundColor: business.accentColor, color: accentTextColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{s !== "Alle" && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 rounded-full ring-2 ring-white/60"
|
||||
style={{ backgroundColor: colors.get(s)?.dot }}
|
||||
/>
|
||||
)}
|
||||
{s === "Alle" ? "Alle Kurse" : sportLabel(s)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Die Woche - gleitet beim Blättern aus der passenden Richtung herein */}
|
||||
<div
|
||||
key={`${weekKey}-${view}`}
|
||||
className={`relative mt-5 transition-[opacity,translate] duration-200 ${EASE_OUT} starting:opacity-0 motion-safe:starting:translate-x-(--week-shift)`}
|
||||
style={{ "--week-shift": `${direction * 16}px` } as CSSProperties}
|
||||
>
|
||||
{isPending ? (
|
||||
<div
|
||||
className="flex justify-center py-24"
|
||||
role="status"
|
||||
aria-label="Kurse werden geladen"
|
||||
>
|
||||
<div className="size-7 animate-spin rounded-full border-2 border-gray-200 border-t-gray-600" />
|
||||
</div>
|
||||
) : !weekHasCourses ? (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 px-4 py-14 text-center">
|
||||
<p className="font-medium text-gray-800">
|
||||
In dieser Woche finden keine Kurse statt.
|
||||
</p>
|
||||
{nextCourseAfterWeek && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToWeek(startOfWeekKey(dayKey(nextCourseAfterWeek.date)))}
|
||||
className="mt-4 rounded-full px-5 py-2 text-sm font-semibold shadow-sm transition-[scale] duration-150 active:scale-95"
|
||||
style={{ backgroundColor: business.accentColor, color: accentTextColor }}
|
||||
>
|
||||
Nächste Kurse ab {formatShortDay(dayKey(nextCourseAfterWeek.date))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : view === "cards" ? (
|
||||
<CourseCardGrid
|
||||
days={days}
|
||||
todayKey={todayKey}
|
||||
coursesByDay={coursesByDay}
|
||||
colors={colors}
|
||||
business={businessInfo}
|
||||
accentColor={business.accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden lg:block">
|
||||
<WeekTimeGrid
|
||||
days={days}
|
||||
todayKey={todayKey}
|
||||
nowTime={nowTime}
|
||||
coursesByDay={coursesByDay}
|
||||
colors={colors}
|
||||
business={businessInfo}
|
||||
accentColor={business.accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<div className="grid grid-cols-7 gap-1.5" role="group" aria-label="Tag wählen">
|
||||
{days.map((key) => {
|
||||
const isPast = key < todayKey;
|
||||
const isActive = key === activeDayKey;
|
||||
const dayCourses = coursesByDay.get(key) ?? [];
|
||||
const dots = Array.from(
|
||||
new Set(dayCourses.map((course) => colors.get(course.sport[0])?.dot))
|
||||
).slice(0, 3);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={isPast}
|
||||
onClick={() => setSelectedDayKey(key)}
|
||||
aria-pressed={isActive}
|
||||
aria-label={formatDayHeading(key, todayKey)}
|
||||
className={`flex flex-col items-center gap-0.5 rounded-2xl py-2.5 transition-[background-color,scale] duration-150 active:scale-95 disabled:opacity-30 ${
|
||||
isActive ? "shadow-sm" : "bg-gray-50 text-gray-800"
|
||||
}`}
|
||||
style={
|
||||
isActive
|
||||
? { backgroundColor: business.accentColor, color: accentTextColor }
|
||||
: key === todayKey
|
||||
? { boxShadow: `inset 0 0 0 1.5px ${business.accentColor}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="text-[11px] font-medium uppercase opacity-70">
|
||||
{formatKey(key, { weekday: "short" })}
|
||||
</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{formatKey(key, { day: "numeric" })}
|
||||
</span>
|
||||
<span aria-hidden className="flex h-1.5 gap-0.5">
|
||||
{dots.map((dot) => (
|
||||
<span
|
||||
key={dot}
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ backgroundColor: dot }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-sm font-semibold text-gray-700">
|
||||
{formatDayHeading(activeDayKey, todayKey)}
|
||||
</h3>
|
||||
{(coursesByDay.get(activeDayKey)?.length ?? 0) > 0 ? (
|
||||
<DayTimeline
|
||||
courses={coursesByDay.get(activeDayKey)!}
|
||||
colors={colors}
|
||||
business={businessInfo}
|
||||
accentColor={business.accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-8 text-center text-sm text-gray-500">
|
||||
An diesem Tag findet kein Kurs statt.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 border-t border-gray-100 pt-4 text-center text-xs text-gray-500">
|
||||
{cancellationSummary}
|
||||
{serviceFeeCents > 0 && ` Alle Preise inkl. ${feeLabel} Servicegebühr.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:items-start">
|
||||
{/* Left: sticky calendar */}
|
||||
<div className="lg:sticky lg:top-6 shrink-0 w-full lg:w-auto">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-3">{formattedSelectedDate}</h2>
|
||||
<div className="border border-gray-200 bg-white rounded-xl shadow-sm p-4 flex justify-center">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={{ before: TODAY }}
|
||||
locale={de}
|
||||
className="[--cell-size:--spacing(10)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: filter + cards */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Filter bündig mit Kalender-Oberkante (gleiche mb wie h2 links) */}
|
||||
<div className="mb-4 mt-11">
|
||||
{allSports.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{allSports.map((sport) => (
|
||||
<button
|
||||
key={sport}
|
||||
onClick={() => setActiveSport(sport)}
|
||||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
resolvedSport === sport
|
||||
? ""
|
||||
: "border border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
style={
|
||||
resolvedSport === sport
|
||||
? { backgroundColor: business.accentColor, color: accentTextColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{sport === "Alle" ? "Alle" : capitalize(sport)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Courses */}
|
||||
{isPending ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
) : displayedCourses.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
Für diesen Tag sind keine Kurse verfügbar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{displayedCourses.map((course) => (
|
||||
<PublicCourseCard
|
||||
key={course.id}
|
||||
course={course}
|
||||
business={{ address: business.address, email: business.email }}
|
||||
defaultOpen={course.id === deepLinkCourseId}
|
||||
accentColor={business.accentColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{deepLinkCourse && (
|
||||
<BookingDialog
|
||||
course={deepLinkCourse}
|
||||
business={businessInfo}
|
||||
open={deepLinkOpen}
|
||||
onOpenChange={setDeepLinkOpen}
|
||||
accentColor={business.accentColor}
|
||||
serviceFeeCents={serviceFeeCents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -395,7 +395,10 @@ const CourseCreateView = ({
|
|||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.push("/courses");
|
||||
// Die Liste lädt nur ein Zeitfenster um heute. Mit der ID springt sie
|
||||
// zum Kurs - auch wenn er Wochen in der Zukunft liegt.
|
||||
const savedId = mode === "edit" ? courseId : "courseId" in result ? result.courseId : null;
|
||||
router.push(savedId ? `/courses?highlight=${encodeURIComponent(savedId)}` : "/courses");
|
||||
} else if ("requiresStandingOrderConfirmation" in result && courseId) {
|
||||
setStandingOrderConfirmInfo({
|
||||
existingBookingsCount: result.existingBookingsCount ?? 0,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { StandingOrderScopeDialog } from "../components/StandingOrderScopeDialog
|
|||
import CancelCourseConfirmDialog from "../components/CancelCourseConfirmDialog";
|
||||
import PublishDraftsDialog from "../components/PublishDraftsDialog";
|
||||
import { useCancelCourse } from "../../hooks/useCancelCourse";
|
||||
import { addDaysToKey, berlinDayEnd, berlinDayStart, dayKey } from "../../lib/course-days";
|
||||
|
||||
type Course = {
|
||||
id: string;
|
||||
|
|
@ -45,6 +46,8 @@ interface CourseListViewProps {
|
|||
initialCourses: Course[];
|
||||
initialDateFrom: Date;
|
||||
initialDateTo: Date;
|
||||
/** Gerade angelegter oder bearbeiteter Kurs - die Liste springt dorthin. */
|
||||
highlightCourseId?: string | null;
|
||||
roomsMap: Record<string, string>;
|
||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||
canCreate: boolean;
|
||||
|
|
@ -56,6 +59,7 @@ const CourseListView = ({
|
|||
initialCourses,
|
||||
initialDateFrom,
|
||||
initialDateTo,
|
||||
highlightCourseId,
|
||||
roomsMap,
|
||||
trainersMap,
|
||||
canCreate,
|
||||
|
|
@ -120,6 +124,16 @@ const CourseListView = ({
|
|||
},
|
||||
});
|
||||
|
||||
// Nach dem Anlegen/Bearbeiten zum Kurs scrollen. Danach den Parameter aus der
|
||||
// URL nehmen, damit ein Neuladen nicht erneut springt.
|
||||
useEffect(() => {
|
||||
if (!highlightCourseId) return;
|
||||
document
|
||||
.getElementById(`course-${highlightCourseId}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
router.replace("/courses", { scroll: false });
|
||||
}, [highlightCourseId, router]);
|
||||
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const LOAD_MORE_DAYS = 14;
|
||||
|
||||
|
|
@ -134,14 +148,13 @@ const CourseListView = ({
|
|||
|
||||
// Lädt nur das neue Zeitfenster nach dem aktuell geladenen Enddatum und hängt
|
||||
// die Kurse an — im Gegensatz zu loadCourses (Filter-Range-Erweiterung), das
|
||||
// den kompletten Bereich neu lädt und ersetzt.
|
||||
// den kompletten Bereich neu lädt und ersetzt. Beginnt eine Millisekunde nach
|
||||
// dem bisherigen Ende, damit kein Tag zwischen den Fenstern verloren geht.
|
||||
const handleLoadMore = async () => {
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const newDateFrom = new Date(loadedDateTo);
|
||||
newDateFrom.setDate(newDateFrom.getDate() + 1);
|
||||
const newDateTo = new Date(loadedDateTo);
|
||||
newDateTo.setDate(newDateTo.getDate() + LOAD_MORE_DAYS);
|
||||
const newDateFrom = new Date(loadedDateTo.getTime() + 1);
|
||||
const newDateTo = berlinDayEnd(addDaysToKey(dayKey(newDateFrom), LOAD_MORE_DAYS - 1));
|
||||
|
||||
const result = await getMyCourses({ dateFrom: newDateFrom, dateTo: newDateTo });
|
||||
if (result.success) {
|
||||
|
|
@ -168,8 +181,7 @@ const CourseListView = ({
|
|||
let newDateTo = loadedDateTo;
|
||||
|
||||
if (dateFrom) {
|
||||
const filterFromDate = new Date(dateFrom);
|
||||
filterFromDate.setHours(0, 0, 0, 0);
|
||||
const filterFromDate = berlinDayStart(dateFrom);
|
||||
if (filterFromDate < loadedDateFrom) {
|
||||
newDateFrom = filterFromDate;
|
||||
needsReload = true;
|
||||
|
|
@ -177,8 +189,7 @@ const CourseListView = ({
|
|||
}
|
||||
|
||||
if (dateTo) {
|
||||
const filterToDate = new Date(dateTo);
|
||||
filterToDate.setHours(0, 0, 0, 0);
|
||||
const filterToDate = berlinDayEnd(dateTo);
|
||||
if (filterToDate > loadedDateTo) {
|
||||
newDateTo = filterToDate;
|
||||
needsReload = true;
|
||||
|
|
@ -492,46 +503,57 @@ const CourseListView = ({
|
|||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{coursesForDate.map((course) => (
|
||||
<CourseListItem
|
||||
<div
|
||||
key={course.id}
|
||||
courseId={course.id}
|
||||
courseName={course.name}
|
||||
room={roomsMap[course.room] || course.room}
|
||||
date={course.date}
|
||||
timeFrom={course.timeFrom}
|
||||
timeTo={course.timeTo}
|
||||
currentParticipants={course._count?.bookings || 0}
|
||||
maxParticipants={course.maxParticipants}
|
||||
price={course.price}
|
||||
level={course.level}
|
||||
trainers={course.trainers}
|
||||
trainersMap={trainersMap}
|
||||
status={course.status}
|
||||
isPast={isPastCourse(course.date)}
|
||||
showDeleteIcon={deleteMode && canDelete}
|
||||
coverImage={course.coverImage ?? undefined}
|
||||
onDelete={
|
||||
canDelete
|
||||
? () =>
|
||||
handleDeleteClick(
|
||||
course.id,
|
||||
course.name,
|
||||
!!(course.isStandingOrder || course.parentCourseId)
|
||||
)
|
||||
id={`course-${course.id}`}
|
||||
className={
|
||||
course.id === highlightCourseId
|
||||
? "rounded-2xl ring-2 ring-[#f4c00c] ring-offset-2 ring-offset-transparent"
|
||||
: undefined
|
||||
}
|
||||
onEdit={canEdit ? () => router.push(`/courses/edit/${course.id}`) : undefined}
|
||||
onCancel={
|
||||
canDelete
|
||||
? () =>
|
||||
handleCancelClick(
|
||||
course.id,
|
||||
course.name,
|
||||
course.status === "cancelled"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
>
|
||||
<CourseListItem
|
||||
courseId={course.id}
|
||||
courseName={course.name}
|
||||
room={roomsMap[course.room] || course.room}
|
||||
date={course.date}
|
||||
timeFrom={course.timeFrom}
|
||||
timeTo={course.timeTo}
|
||||
currentParticipants={course._count?.bookings || 0}
|
||||
maxParticipants={course.maxParticipants}
|
||||
price={course.price}
|
||||
level={course.level}
|
||||
trainers={course.trainers}
|
||||
trainersMap={trainersMap}
|
||||
status={course.status}
|
||||
isPast={isPastCourse(course.date)}
|
||||
showDeleteIcon={deleteMode && canDelete}
|
||||
coverImage={course.coverImage ?? undefined}
|
||||
onDelete={
|
||||
canDelete
|
||||
? () =>
|
||||
handleDeleteClick(
|
||||
course.id,
|
||||
course.name,
|
||||
!!(course.isStandingOrder || course.parentCourseId)
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
canEdit ? () => router.push(`/courses/edit/${course.id}`) : undefined
|
||||
}
|
||||
onCancel={
|
||||
canDelete
|
||||
? () =>
|
||||
handleCancelClick(
|
||||
course.id,
|
||||
course.name,
|
||||
course.status === "cancelled"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface CourseViewProps {
|
|||
initialCourses: Course[];
|
||||
initialDateFrom: Date;
|
||||
initialDateTo: Date;
|
||||
highlightCourseId?: string | null;
|
||||
roomsMap: Record<string, string>;
|
||||
trainersMap: Record<string, { label: string; pbSrc?: string }>;
|
||||
canCreate: boolean;
|
||||
|
|
@ -45,6 +46,7 @@ const CourseView = ({
|
|||
initialCourses,
|
||||
initialDateFrom,
|
||||
initialDateTo,
|
||||
highlightCourseId,
|
||||
roomsMap,
|
||||
trainersMap,
|
||||
canCreate,
|
||||
|
|
@ -73,6 +75,7 @@ const CourseView = ({
|
|||
initialCourses={initialCourses}
|
||||
initialDateFrom={initialDateFrom}
|
||||
initialDateTo={initialDateTo}
|
||||
highlightCourseId={highlightCourseId}
|
||||
roomsMap={roomsMap}
|
||||
trainersMap={trainersMap}
|
||||
canCreate={canCreate}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import {
|
|||
} from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import {
|
||||
bookingRevenueSelect as revenueSelect,
|
||||
courseAmountCents,
|
||||
sumCourseRevenueCents,
|
||||
} from "@/src/lib/booking-amounts";
|
||||
|
||||
export type DashboardStats = {
|
||||
coursesToday: number;
|
||||
|
|
@ -146,49 +151,47 @@ export async function getDashboardStats(): Promise<
|
|||
date: { gte: lastMonthStart, lte: lastMonthEnd },
|
||||
},
|
||||
}),
|
||||
prisma.courseBooking.aggregate({
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { createdBy: managerId },
|
||||
paymentStatus: "paid",
|
||||
createdAt: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
_sum: { amountPaid: true },
|
||||
select: revenueSelect,
|
||||
}),
|
||||
prisma.courseBooking.aggregate({
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { createdBy: managerId },
|
||||
paymentStatus: "partially_refunded",
|
||||
createdAt: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
_sum: { amountPaid: true, refundedAmount: true },
|
||||
select: revenueSelect,
|
||||
}),
|
||||
prisma.courseBooking.aggregate({
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { createdBy: managerId },
|
||||
paymentStatus: "paid",
|
||||
createdAt: { gte: lastMonthStart, lte: lastMonthEnd },
|
||||
},
|
||||
_sum: { amountPaid: true },
|
||||
select: revenueSelect,
|
||||
}),
|
||||
prisma.courseBooking.aggregate({
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { createdBy: managerId },
|
||||
paymentStatus: "partially_refunded",
|
||||
createdAt: { gte: lastMonthStart, lte: lastMonthEnd },
|
||||
},
|
||||
_sum: { amountPaid: true, refundedAmount: true },
|
||||
select: revenueSelect,
|
||||
}),
|
||||
]);
|
||||
|
||||
const revenueThisMonth =
|
||||
(revenueThisMonthPaid._sum.amountPaid ?? 0) +
|
||||
(revenueThisMonthPartial._sum.amountPaid ?? 0) -
|
||||
(revenueThisMonthPartial._sum.refundedAmount ?? 0);
|
||||
sumCourseRevenueCents(revenueThisMonthPaid) +
|
||||
sumCourseRevenueCents(revenueThisMonthPartial);
|
||||
|
||||
const revenueLastMonth =
|
||||
(revenueLastMonthPaid._sum.amountPaid ?? 0) +
|
||||
(revenueLastMonthPartial._sum.amountPaid ?? 0) -
|
||||
(revenueLastMonthPartial._sum.refundedAmount ?? 0);
|
||||
sumCourseRevenueCents(revenueLastMonthPaid) +
|
||||
sumCourseRevenueCents(revenueLastMonthPartial);
|
||||
|
||||
return {
|
||||
coursesToday,
|
||||
|
|
@ -232,7 +235,7 @@ export async function getDashboardRevenueChart(): Promise<
|
|||
paymentStatus: "paid",
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
},
|
||||
select: { createdAt: true, amountPaid: true },
|
||||
select: { createdAt: true, ...revenueSelect },
|
||||
}),
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
|
|
@ -240,7 +243,7 @@ export async function getDashboardRevenueChart(): Promise<
|
|||
paymentStatus: "partially_refunded",
|
||||
createdAt: { gte: rangeStart, lte: rangeEnd },
|
||||
},
|
||||
select: { createdAt: true, amountPaid: true, refundedAmount: true },
|
||||
select: { createdAt: true, ...revenueSelect },
|
||||
}),
|
||||
prisma.course.findMany({
|
||||
where: {
|
||||
|
|
@ -267,7 +270,7 @@ export async function getDashboardRevenueChart(): Promise<
|
|||
const key = format(new Date(b.createdAt), "yyyy-MM");
|
||||
const entry = byMonth.get(key);
|
||||
if (entry) {
|
||||
entry.revenue += b.amountPaid ?? 0;
|
||||
entry.revenue += sumCourseRevenueCents([b]);
|
||||
entry.bookings += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -276,7 +279,7 @@ export async function getDashboardRevenueChart(): Promise<
|
|||
const key = format(new Date(b.createdAt), "yyyy-MM");
|
||||
const entry = byMonth.get(key);
|
||||
if (entry) {
|
||||
entry.revenue += (b.amountPaid ?? 0) - (b.refundedAmount ?? 0);
|
||||
entry.revenue += sumCourseRevenueCents([b]);
|
||||
entry.bookings += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -398,7 +401,9 @@ export async function getDashboardRecentBookings(): Promise<
|
|||
customerName:
|
||||
b.guestLead?.name ?? b.user?.name ?? b.guestLead?.email ?? b.user?.email ?? "Anonym",
|
||||
courseName: b.course.name,
|
||||
amountPaid: b.amountPaid,
|
||||
// Was dem Studio aus dieser Zahlung zusteht - ohne Servicegebühr.
|
||||
// "pending" hat noch keinen Betrag, das bleibt null statt 0.
|
||||
amountPaid: b.amountPaid !== null ? courseAmountCents(b) : null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export type KundeBooking = {
|
|||
timeFrom: string;
|
||||
timeTo: string;
|
||||
roomName: string;
|
||||
// Felder für den PublicCourseCard-Dialog
|
||||
// Felder für den BookingDialog
|
||||
level: string;
|
||||
price: number;
|
||||
maxParticipants: number;
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import type { KundeBooking } from "../../actions/kunde-detail-actions";
|
|||
import { cancelKundeBooking } from "../../actions/kunde-cancel-booking-actions";
|
||||
import CancelBookingConfirmDialog from "@/src/modules/courses/ui/components/CancelBookingConfirmDialog";
|
||||
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
|
||||
import { BookingDialog } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||
import type { PublicCourse } from "@/src/modules/courses/ui/components/PublicCourseCard";
|
||||
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";
|
||||
import type { PublicCourse } from "@/src/modules/courses/ui/components/BookingDialog";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { prisma } from "@/src/lib/prisma";
|
|||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { verifyAddressExists } from "@/src/lib/geocoding/verify-address";
|
||||
import { toTaxColumns, validateTaxSettings, type TaxSettingsInput } from "../lib/tax-settings";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
|
||||
|
|
@ -14,7 +15,6 @@ export async function updateBusinessInfo(data: {
|
|||
place: string;
|
||||
email: string;
|
||||
title: string;
|
||||
ustId?: string;
|
||||
banking?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
|
|
@ -65,7 +65,6 @@ export async function updateBusinessInfo(data: {
|
|||
address,
|
||||
email,
|
||||
title: data.title.trim(),
|
||||
ustId: data.ustId?.trim() || null,
|
||||
banking: data.banking?.trim() || null,
|
||||
},
|
||||
select: { slug: true },
|
||||
|
|
@ -79,3 +78,38 @@ export async function updateBusinessInfo(data: {
|
|||
return { success: false, error: "Fehler beim Speichern der Business-Daten." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die steuerlichen Angaben, die auf den Kundenrechnungen stehen.
|
||||
* Die USt-IdNr. gehört dazu und wird nur hier geschrieben - nicht mehr über
|
||||
* updateBusinessInfo, damit das Speichern der Adresse sie nicht überschreibt.
|
||||
*/
|
||||
export async function updateBusinessTaxSettings(
|
||||
data: { businessId: string } & TaxSettingsInput
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
const manager = await requireManager();
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: data.businessId },
|
||||
select: { managerId: true },
|
||||
});
|
||||
|
||||
if (!business || business.managerId !== manager.id) {
|
||||
return { success: false, error: "Nicht berechtigt." };
|
||||
}
|
||||
|
||||
const result = validateTaxSettings(data);
|
||||
if (!result.ok) return { success: false, error: result.error };
|
||||
|
||||
await prisma.business.update({
|
||||
where: { id: data.businessId },
|
||||
data: toTaxColumns(result.settings),
|
||||
});
|
||||
|
||||
(revalidateTag as (tag: string, type: "max") => void)(`user-data-${manager.email}`, "max");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Fehler beim Speichern der Steuerangaben." };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
src/modules/settings/lib/tax-settings.ts
Normal file
130
src/modules/settings/lib/tax-settings.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Steuerliche Angaben eines Studios für die Rechnungen an seine Kunden.
|
||||
*
|
||||
* Bewusst ohne Standardwert: Ob ein Studio Umsatzsteuer ausweisen muss, lässt
|
||||
* sich aus keinem anderen Feld ableiten. Früher galt "keine USt-IdNr =
|
||||
* Kleinunternehmer" - das ist falsch, viele regelbesteuerte Studios haben nur
|
||||
* eine Steuernummer, und ihre Rechnung hätte dann fälschlich § 19 UStG
|
||||
* genannt. Solange nichts hinterlegt ist, entstehen deshalb keine Rechnungen.
|
||||
*
|
||||
* Bewusst in einer eigenen lib-Datei: Formular, Server Action und Rechnung
|
||||
* teilen sich dieselbe Validierung.
|
||||
*/
|
||||
|
||||
export const TAX_STATUSES = ["regular", "small_business", "exempt"] as const;
|
||||
export type TaxStatus = (typeof TAX_STATUSES)[number];
|
||||
|
||||
export const VAT_RATES = [19, 7] as const;
|
||||
export type VatRate = (typeof VAT_RATES)[number];
|
||||
|
||||
export type TaxSettings =
|
||||
| { status: "regular"; vatRate: VatRate; taxNumber: string | null; ustId: string | null }
|
||||
| { status: "small_business"; taxNumber: string | null; ustId: string | null }
|
||||
| {
|
||||
status: "exempt";
|
||||
exemptionNote: string;
|
||||
taxNumber: string | null;
|
||||
ustId: string | null;
|
||||
};
|
||||
|
||||
export type TaxSettingsColumns = {
|
||||
taxStatus: string | null;
|
||||
vatRate: number | null;
|
||||
taxNumber: string | null;
|
||||
ustId: string | null;
|
||||
taxExemptionNote: string | null;
|
||||
};
|
||||
|
||||
export type TaxSettingsInput = {
|
||||
taxStatus: string;
|
||||
vatRate?: number | null;
|
||||
taxNumber?: string | null;
|
||||
ustId?: string | null;
|
||||
taxExemptionNote?: string | null;
|
||||
};
|
||||
|
||||
type ValidationResult = { ok: true; settings: TaxSettings } | { ok: false; error: string };
|
||||
|
||||
// EU-Format: Länderkürzel plus 8 bis 12 Zeichen, z.B. DE123456789.
|
||||
const UST_ID_PATTERN = /^[A-Z]{2}[0-9A-Z]{8,12}$/;
|
||||
|
||||
function clean(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Prüft die Eingaben aus dem Formular und normalisiert sie. */
|
||||
export function validateTaxSettings(input: TaxSettingsInput): ValidationResult {
|
||||
if (!TAX_STATUSES.includes(input.taxStatus as TaxStatus)) {
|
||||
return { ok: false, error: "Bitte wähle aus, wie dein Studio besteuert wird." };
|
||||
}
|
||||
|
||||
const taxNumber = clean(input.taxNumber);
|
||||
const ustId = clean(input.ustId)?.replace(/\s+/g, "").toUpperCase() ?? null;
|
||||
|
||||
if (ustId && !UST_ID_PATTERN.test(ustId)) {
|
||||
return { ok: false, error: "Die USt-IdNr. hat kein gültiges Format (z. B. DE123456789)." };
|
||||
}
|
||||
|
||||
// Wer Umsatzsteuer ausweist oder sich auf eine Befreiung beruft, muss auf
|
||||
// der Rechnung Steuernummer oder USt-IdNr. angeben (§ 14 Abs. 4 Nr. 2 UStG).
|
||||
const needsTaxId = input.taxStatus === "regular" || input.taxStatus === "exempt";
|
||||
if (needsTaxId && !taxNumber && !ustId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Bitte gib deine Steuernummer oder USt-IdNr. an - sie muss auf jeder Rechnung stehen.",
|
||||
};
|
||||
}
|
||||
|
||||
if (input.taxStatus === "regular") {
|
||||
if (!VAT_RATES.includes(input.vatRate as VatRate)) {
|
||||
return { ok: false, error: "Bitte wähle den Umsatzsteuersatz für deine Kurse." };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
settings: { status: "regular", vatRate: input.vatRate as VatRate, taxNumber, ustId },
|
||||
};
|
||||
}
|
||||
|
||||
if (input.taxStatus === "exempt") {
|
||||
const exemptionNote = clean(input.taxExemptionNote);
|
||||
if (!exemptionNote) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Bitte gib den Grund der Steuerbefreiung an, wie er auf der Rechnung stehen soll " +
|
||||
"(z. B. „Steuerfrei nach § 4 Nr. 21 UStG“).",
|
||||
};
|
||||
}
|
||||
return { ok: true, settings: { status: "exempt", exemptionNote, taxNumber, ustId } };
|
||||
}
|
||||
|
||||
return { ok: true, settings: { status: "small_business", taxNumber, ustId } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die gespeicherten Spalten eines Business. null = noch nicht (gültig)
|
||||
* hinterlegt - dann darf keine Rechnung entstehen.
|
||||
*/
|
||||
export function readTaxSettings(columns: TaxSettingsColumns): TaxSettings | null {
|
||||
if (!columns.taxStatus) return null;
|
||||
const result = validateTaxSettings({
|
||||
taxStatus: columns.taxStatus,
|
||||
vatRate: columns.vatRate,
|
||||
taxNumber: columns.taxNumber,
|
||||
ustId: columns.ustId,
|
||||
taxExemptionNote: columns.taxExemptionNote,
|
||||
});
|
||||
return result.ok ? result.settings : null;
|
||||
}
|
||||
|
||||
/** Die Spalten, wie sie in die DB geschrieben werden - nicht genutzte Felder werden geleert. */
|
||||
export function toTaxColumns(settings: TaxSettings): TaxSettingsColumns {
|
||||
return {
|
||||
taxStatus: settings.status,
|
||||
vatRate: settings.status === "regular" ? settings.vatRate : null,
|
||||
taxNumber: settings.taxNumber,
|
||||
ustId: settings.ustId,
|
||||
taxExemptionNote: settings.status === "exempt" ? settings.exemptionNote : null,
|
||||
};
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { Button } from "@/src/components/ui/button";
|
|||
import { updateBusinessInfo } from "../../actions/business-settings-actions";
|
||||
import { updateProfileInfo } from "../../actions/personal-settings-actions";
|
||||
import { toast } from "sonner";
|
||||
import TaxSettingsCard, { type TaxSettingsCardProps } from "./TaxSettingsCard";
|
||||
|
||||
// Parses "Musterstraße 1, 12345 Musterstadt" (the format used at registration)
|
||||
// back into its parts so the address can be edited via AddressAutocompleteComponent.
|
||||
|
|
@ -28,9 +29,9 @@ export type BusinessSettingsFormProps = {
|
|||
address: string;
|
||||
email: string;
|
||||
title: string;
|
||||
ustId: string | null;
|
||||
banking: string | null;
|
||||
managerTel: string | null;
|
||||
tax: Omit<TaxSettingsCardProps, "businessId">;
|
||||
};
|
||||
|
||||
function Card({
|
||||
|
|
@ -59,9 +60,9 @@ const BusinessSettingsForm = ({
|
|||
address: initialAddress,
|
||||
email: initialEmail,
|
||||
title: initialTitle,
|
||||
ustId: initialUstId,
|
||||
banking: initialBanking,
|
||||
managerTel: initialTel,
|
||||
tax,
|
||||
}: BusinessSettingsFormProps) => {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
|
|
@ -71,7 +72,6 @@ const BusinessSettingsForm = ({
|
|||
const [place, setPlace] = useState(initialParsed.place);
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [ustId, setUstId] = useState(initialUstId ?? "");
|
||||
const [banking, setBanking] = useState(initialBanking ?? "");
|
||||
const [tel, setTel] = useState(initialTel ?? "");
|
||||
|
||||
|
|
@ -89,7 +89,6 @@ const BusinessSettingsForm = ({
|
|||
place,
|
||||
email,
|
||||
title,
|
||||
ustId,
|
||||
banking,
|
||||
});
|
||||
if (result.success) {
|
||||
|
|
@ -165,7 +164,7 @@ const BusinessSettingsForm = ({
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Kontakt & Rechtliches" subtitle="E-Mail, USt-IdNr. und Bankverbindung">
|
||||
<Card title="Kontakt & Bankverbindung" subtitle="E-Mail und Bankverbindung">
|
||||
<div className="space-y-4">
|
||||
<InputComponent
|
||||
label="Geschäfts-E-Mail"
|
||||
|
|
@ -176,15 +175,6 @@ const BusinessSettingsForm = ({
|
|||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<InputComponent
|
||||
label="USt-IdNr. (optional)"
|
||||
type="text"
|
||||
id="businessUstId"
|
||||
isLabel
|
||||
placeholder="DE123456789"
|
||||
value={ustId}
|
||||
onChange={(e) => setUstId(e.target.value)}
|
||||
/>
|
||||
<InputComponent
|
||||
label="Bankverbindung (optional)"
|
||||
type="text"
|
||||
|
|
@ -202,6 +192,8 @@ const BusinessSettingsForm = ({
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<TaxSettingsCard businessId={businessId} {...tax} />
|
||||
|
||||
<Card title="Geschäftsnummer" subtitle="Telefonnummer des Unternehmens">
|
||||
<InputComponent
|
||||
label="Telefonnummer"
|
||||
|
|
|
|||
181
src/modules/settings/ui/components/TaxSettingsCard.tsx
Normal file
181
src/modules/settings/ui/components/TaxSettingsCard.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import InputComponent from "@/src/components/layout/InputComponent";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { updateBusinessTaxSettings } from "../../actions/business-settings-actions";
|
||||
import type { TaxStatus } from "../../lib/tax-settings";
|
||||
|
||||
export type TaxSettingsCardProps = {
|
||||
businessId: string;
|
||||
taxStatus: string | null;
|
||||
vatRate: number | null;
|
||||
taxNumber: string | null;
|
||||
ustId: string | null;
|
||||
taxExemptionNote: string | null;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { value: TaxStatus; label: string; hint: string }[] = [
|
||||
{
|
||||
value: "regular",
|
||||
label: "Umsatzsteuerpflichtig",
|
||||
hint: "Die Rechnung weist Netto, Umsatzsteuer und Brutto aus.",
|
||||
},
|
||||
{
|
||||
value: "small_business",
|
||||
label: "Kleinunternehmer (§ 19 UStG)",
|
||||
hint: "Keine Umsatzsteuer, die Rechnung nennt die Kleinunternehmerregelung.",
|
||||
},
|
||||
{
|
||||
value: "exempt",
|
||||
label: "Umsatzsteuerfrei",
|
||||
hint: "Z. B. bestimmte Bildungs- oder Gesundheitsleistungen nach § 4 UStG.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Steuerliche Angaben für die Rechnungen an die Kunden des Studios. Ohne sie
|
||||
* erstellt Kumasy keine Rechnungen - siehe tax-settings.ts für den Grund.
|
||||
*/
|
||||
const TaxSettingsCard = ({
|
||||
businessId,
|
||||
taxStatus: initialStatus,
|
||||
vatRate: initialVatRate,
|
||||
taxNumber: initialTaxNumber,
|
||||
ustId: initialUstId,
|
||||
taxExemptionNote: initialExemptionNote,
|
||||
}: TaxSettingsCardProps) => {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [status, setStatus] = useState<TaxStatus | null>(
|
||||
(initialStatus as TaxStatus | null) ?? null
|
||||
);
|
||||
const [vatRate, setVatRate] = useState<number>(initialVatRate ?? 19);
|
||||
const [taxNumber, setTaxNumber] = useState(initialTaxNumber ?? "");
|
||||
const [ustId, setUstId] = useState(initialUstId ?? "");
|
||||
const [exemptionNote, setExemptionNote] = useState(initialExemptionNote ?? "");
|
||||
const [isConfigured, setIsConfigured] = useState(Boolean(initialStatus));
|
||||
|
||||
const handleSave = () => {
|
||||
if (!status) {
|
||||
toast.error("Bitte wähle aus, wie dein Studio besteuert wird.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await updateBusinessTaxSettings({
|
||||
businessId,
|
||||
taxStatus: status,
|
||||
vatRate: status === "regular" ? vatRate : null,
|
||||
taxNumber,
|
||||
ustId,
|
||||
taxExemptionNote: status === "exempt" ? exemptionNote : null,
|
||||
});
|
||||
if (result.success) {
|
||||
setIsConfigured(true);
|
||||
toast.success("Steuerangaben gespeichert. Rechnungen enthalten jetzt diese Angaben.");
|
||||
} else {
|
||||
toast.error(result.error ?? "Fehler beim Speichern.");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:col-span-2 border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl shadow-sm p-6 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Steuerangaben für Rechnungen</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
Diese Angaben stehen auf jeder Rechnung an deine Kunden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isConfigured && (
|
||||
<p className="text-sm rounded-lg border border-amber-200 bg-amber-50 text-amber-800 px-3 py-2">
|
||||
Solange hier nichts hinterlegt ist, kann Kumasy keine Rechnungen für deine Kunden
|
||||
erstellen. Im Zweifel frag deinen Steuerberater, welche Option für dich gilt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<fieldset className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<legend className="sr-only">Besteuerung</legend>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={status === option.value}
|
||||
onClick={() => setStatus(option.value)}
|
||||
className={`text-left rounded-xl border p-3 transition-colors ${
|
||||
status === option.value
|
||||
? "border-gray-900 bg-white shadow-sm"
|
||||
: "border-gray-200 bg-white/60 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-medium text-gray-900">{option.label}</span>
|
||||
<span className="block text-xs text-gray-500 mt-1">{option.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{status === "regular" && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="vatRate" className="text-sm font-medium text-gray-700">
|
||||
Umsatzsteuersatz für Kurse
|
||||
</label>
|
||||
<select
|
||||
id="vatRate"
|
||||
value={vatRate}
|
||||
onChange={(e) => setVatRate(Number(e.target.value))}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
<option value={19}>19 % (Regelsatz)</option>
|
||||
<option value={7}>7 % (ermäßigt)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<InputComponent
|
||||
label={status === "small_business" ? "Steuernummer (optional)" : "Steuernummer"}
|
||||
type="text"
|
||||
id="taxNumber"
|
||||
isLabel
|
||||
placeholder="12/345/67890"
|
||||
value={taxNumber}
|
||||
onChange={(e) => setTaxNumber(e.target.value)}
|
||||
/>
|
||||
<InputComponent
|
||||
label="USt-IdNr. (optional, wenn Steuernummer angegeben)"
|
||||
type="text"
|
||||
id="businessUstId"
|
||||
isLabel
|
||||
placeholder="DE123456789"
|
||||
value={ustId}
|
||||
onChange={(e) => setUstId(e.target.value)}
|
||||
/>
|
||||
{status === "exempt" && (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label htmlFor="taxExemptionNote" className="text-sm font-medium text-gray-700">
|
||||
Grund der Steuerbefreiung (steht so auf der Rechnung)
|
||||
</label>
|
||||
<textarea
|
||||
id="taxExemptionNote"
|
||||
rows={2}
|
||||
value={exemptionNote}
|
||||
onChange={(e) => setExemptionNote(e.target.value)}
|
||||
placeholder="Steuerfrei nach § 4 Nr. 21 UStG"
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-1">
|
||||
<Button onClick={handleSave} disabled={pending || !status} size="sm" className="w-fit">
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaxSettingsCard;
|
||||
|
|
@ -15,6 +15,10 @@ const SettingsBusinessPageView = async () => {
|
|||
title: true,
|
||||
ustId: true,
|
||||
banking: true,
|
||||
taxStatus: true,
|
||||
vatRate: true,
|
||||
taxNumber: true,
|
||||
taxExemptionNote: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -41,9 +45,15 @@ const SettingsBusinessPageView = async () => {
|
|||
address={business.address}
|
||||
email={business.email}
|
||||
title={business.title}
|
||||
ustId={business.ustId}
|
||||
banking={business.banking}
|
||||
managerTel={manager.tel}
|
||||
tax={{
|
||||
taxStatus: business.taxStatus,
|
||||
vatRate: business.vatRate,
|
||||
taxNumber: business.taxNumber,
|
||||
ustId: business.ustId,
|
||||
taxExemptionNote: business.taxExemptionNote,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import type { ManagerData } from "@/src/lib/auth/getUser";
|
|||
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
||||
import type { ZahlungenFilters } from "./zahlungen-actions";
|
||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { courseAmountCents } from "@/src/lib/booking-amounts";
|
||||
import { readTaxSettings, type TaxSettings } from "@/src/modules/settings/lib/tax-settings";
|
||||
import { formatBillingAddressLines, readBillingAddress } from "@/src/lib/billing-address";
|
||||
|
||||
// Obergrenze für den ZIP-Sammel-Export, damit ein sehr weiter Filter nicht
|
||||
// eine unbegrenzte Zahl an PDFs auf einmal rendert.
|
||||
|
|
@ -18,10 +21,8 @@ export type InvoiceCustomer = {
|
|||
name: string;
|
||||
email: string;
|
||||
isGuest: boolean;
|
||||
street: string | null;
|
||||
houseNumber: string | null;
|
||||
plz: string | null;
|
||||
city: string | null;
|
||||
/** Anschrift des Rechnungsempfängers, leer wenn keine bekannt ist. */
|
||||
addressLines: string[];
|
||||
};
|
||||
|
||||
export type InvoiceData = {
|
||||
|
|
@ -31,7 +32,8 @@ export type InvoiceData = {
|
|||
name: string;
|
||||
address: string;
|
||||
email: string;
|
||||
ustId: string | null;
|
||||
/** Bestimmt, ob und wie Umsatzsteuer auf der Rechnung erscheint. */
|
||||
tax: TaxSettings;
|
||||
banking: string | null;
|
||||
logoUrl: string | null;
|
||||
accentColor: string;
|
||||
|
|
@ -46,6 +48,14 @@ export type InvoiceData = {
|
|||
refundedAmountCents: number; // 0, außer bei Teilerstattung
|
||||
};
|
||||
|
||||
/**
|
||||
* Ergebnis der Rechnungserstellung. Fehlende Steuerangaben sind ein eigener
|
||||
* Grund, damit die Oberfläche dem Manager sagen kann, was zu tun ist.
|
||||
*/
|
||||
export type InvoiceResult =
|
||||
| { ok: true; data: InvoiceData }
|
||||
| { ok: false; reason: "not_invoiceable" | "missing_tax_settings" };
|
||||
|
||||
function formatInvoiceNumber(businessId: string, counter: number): string {
|
||||
const year = new Date().getFullYear();
|
||||
return `INV-${businessId.slice(0, 8).toUpperCase()}-${year}-${String(counter).padStart(5, "0")}`;
|
||||
|
|
@ -104,7 +114,10 @@ async function ensureInvoiceNumber(
|
|||
}
|
||||
|
||||
const invoiceBookingSelect = {
|
||||
billingAddress: true,
|
||||
amountPaid: true,
|
||||
platformFee: true,
|
||||
platformFeeOnTop: true,
|
||||
refundedAmount: true,
|
||||
paymentStatus: true,
|
||||
course: {
|
||||
|
|
@ -119,6 +132,10 @@ const invoiceBookingSelect = {
|
|||
address: true,
|
||||
email: true,
|
||||
ustId: true,
|
||||
taxStatus: true,
|
||||
vatRate: true,
|
||||
taxNumber: true,
|
||||
taxExemptionNote: true,
|
||||
banking: true,
|
||||
branding: true,
|
||||
},
|
||||
|
|
@ -139,32 +156,49 @@ type InvoiceBookingRow = Awaited<
|
|||
function toInvoiceData(
|
||||
booking: InvoiceBookingRow,
|
||||
invoiceNumber: string,
|
||||
issuedAt: Date
|
||||
issuedAt: Date,
|
||||
tax: TaxSettings
|
||||
): InvoiceData | null {
|
||||
if (!booking.course.business) return null;
|
||||
|
||||
// Vorrang hat die Adresse aus dem Checkout - sie ist Pflicht und gehört zu
|
||||
// genau dieser Buchung. Ältere Buchungen haben keine; dann gilt die
|
||||
// Profiladresse des Kunden, bei Gästen bleibt die Anschrift leer.
|
||||
const billing = readBillingAddress(booking.billingAddress);
|
||||
const profileAddressLines =
|
||||
booking.user?.street && booking.user.plz && booking.user.city
|
||||
? [
|
||||
`${booking.user.street} ${booking.user.houseNumber ?? ""}`.trim(),
|
||||
`${booking.user.plz} ${booking.user.city}`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const customer: InvoiceCustomer = booking.user
|
||||
? {
|
||||
name: booking.user.name ?? booking.user.email,
|
||||
name: billing?.name ?? booking.user.name ?? booking.user.email,
|
||||
email: booking.user.email,
|
||||
isGuest: false,
|
||||
street: booking.user.street,
|
||||
houseNumber: booking.user.houseNumber,
|
||||
plz: booking.user.plz,
|
||||
city: booking.user.city,
|
||||
addressLines: billing ? formatBillingAddressLines(billing) : profileAddressLines,
|
||||
}
|
||||
: {
|
||||
name: booking.guestLead?.name ?? booking.guestLead?.email ?? "Gast",
|
||||
name: billing?.name ?? booking.guestLead?.name ?? booking.guestLead?.email ?? "Gast",
|
||||
email: booking.guestLead?.email ?? "–",
|
||||
isGuest: true,
|
||||
street: null,
|
||||
houseNumber: null,
|
||||
plz: null,
|
||||
city: null,
|
||||
addressLines: billing ? formatBillingAddressLines(billing) : [],
|
||||
};
|
||||
|
||||
const branding = toEmailBranding(booking.course.business);
|
||||
|
||||
// Diese Rechnung stellt das STUDIO aus - mit dessen Briefkopf, USt-ID bzw.
|
||||
// §19-Hinweis. Ausgewiesen wird deshalb nur der Kurspreis; die
|
||||
// Servicegebühr ist eine Leistung der Plattform gegenüber dem Kunden und
|
||||
// gehört nicht auf diesen Beleg. Fehlt amountPaid (Altdaten), bleibt der
|
||||
// Kurspreis als Näherung.
|
||||
const originalAmountCents =
|
||||
booking.amountPaid !== null
|
||||
? courseAmountCents(booking)
|
||||
: Math.round(booking.course.price * 100);
|
||||
|
||||
return {
|
||||
invoiceNumber,
|
||||
issueDate: issuedAt.toISOString(),
|
||||
|
|
@ -172,7 +206,7 @@ function toInvoiceData(
|
|||
name: booking.course.business.name,
|
||||
address: booking.course.business.address,
|
||||
email: booking.course.business.email,
|
||||
ustId: booking.course.business.ustId,
|
||||
tax,
|
||||
banking: booking.course.business.banking,
|
||||
logoUrl: branding.logoUrl,
|
||||
accentColor: branding.accentColor,
|
||||
|
|
@ -186,10 +220,8 @@ function toInvoiceData(
|
|||
// Betrag als Endbetrag, weist aber zusätzlich den ursprünglichen Preis und
|
||||
// die Erstattung separat aus - sonst wäre für Kunde und Buchhaltung nicht
|
||||
// nachvollziehbar, warum die Rechnung von der Kursgebühr abweicht.
|
||||
amountCents:
|
||||
(booking.amountPaid ?? Math.round(booking.course.price * 100)) -
|
||||
(booking.refundedAmount ?? 0),
|
||||
originalAmountCents: booking.amountPaid ?? Math.round(booking.course.price * 100),
|
||||
amountCents: originalAmountCents - (booking.refundedAmount ?? 0),
|
||||
originalAmountCents,
|
||||
refundedAmountCents: booking.refundedAmount ?? 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -200,13 +232,13 @@ function toInvoiceData(
|
|||
* aufzurufen), damit der next/navigation-Redirect bei fehlender Session nur an
|
||||
* der Aufrufstelle (Route Handler) ausgelöst werden kann und nicht in einem
|
||||
* try/catch landet, das ihn in einen 500er verwandeln würde.
|
||||
* Gibt null zurück, wenn die Buchung nicht existiert, nicht bezahlt ist, oder
|
||||
* nicht zu einem Business des angefragten Managers gehört.
|
||||
* Liefert not_invoiceable, wenn die Buchung nicht existiert, nicht bezahlt ist,
|
||||
* oder nicht zu einem Business des angefragten Managers gehört.
|
||||
*/
|
||||
export async function assembleInvoiceData(
|
||||
bookingId: string,
|
||||
manager: ManagerData
|
||||
): Promise<InvoiceData | null> {
|
||||
): Promise<InvoiceResult> {
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
const booking = await prisma.courseBooking.findFirst({
|
||||
|
|
@ -217,18 +249,25 @@ export async function assembleInvoiceData(
|
|||
select: invoiceBookingSelect,
|
||||
});
|
||||
|
||||
if (!booking || !booking.course.business) return null;
|
||||
if (!booking || !booking.course.business) return { ok: false, reason: "not_invoiceable" };
|
||||
if (
|
||||
!INVOICEABLE_STATUSES.includes(booking.paymentStatus as (typeof INVOICEABLE_STATUSES)[number])
|
||||
)
|
||||
return null;
|
||||
return { ok: false, reason: "not_invoiceable" };
|
||||
|
||||
// VOR der Nummernvergabe prüfen: Rechnungsnummern müssen lückenlos sein.
|
||||
// Eine Nummer zu ziehen und dann keine Rechnung zu erstellen, hinterließe
|
||||
// eine Lücke im Nummernkreis.
|
||||
const tax = readTaxSettings(booking.course.business);
|
||||
if (!tax) return { ok: false, reason: "missing_tax_settings" };
|
||||
|
||||
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
|
||||
bookingId,
|
||||
booking.course.business.id
|
||||
);
|
||||
|
||||
return toInvoiceData(booking, invoiceNumber, issuedAt);
|
||||
const data = toInvoiceData(booking, invoiceNumber, issuedAt, tax);
|
||||
return data ? { ok: true, data } : { ok: false, reason: "not_invoiceable" };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -240,8 +279,8 @@ export async function assembleInvoiceData(
|
|||
export async function assembleInvoiceDataBatch(
|
||||
bookingIds: string[],
|
||||
manager: ManagerData
|
||||
): Promise<InvoiceData[]> {
|
||||
if (bookingIds.length === 0) return [];
|
||||
): Promise<{ invoices: InvoiceData[]; businessesMissingTax: string[] }> {
|
||||
if (bookingIds.length === 0) return { invoices: [], businessesMissingTax: [] };
|
||||
|
||||
const businessIds = manager.businesses.map((b) => b.id);
|
||||
|
||||
|
|
@ -255,19 +294,27 @@ export async function assembleInvoiceDataBatch(
|
|||
});
|
||||
|
||||
const results: InvoiceData[] = [];
|
||||
const businessesMissingTax = new Set<string>();
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.course.business) continue;
|
||||
|
||||
// Wie in assembleInvoiceData: ohne Steuerangaben keine Nummer ziehen.
|
||||
const tax = readTaxSettings(booking.course.business);
|
||||
if (!tax) {
|
||||
businessesMissingTax.add(booking.course.business.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { invoiceNumber, issuedAt } = await ensureInvoiceNumber(
|
||||
booking.id,
|
||||
booking.course.business.id
|
||||
);
|
||||
const invoiceData = toInvoiceData(booking, invoiceNumber, issuedAt);
|
||||
const invoiceData = toInvoiceData(booking, invoiceNumber, issuedAt, tax);
|
||||
if (invoiceData) results.push(invoiceData);
|
||||
}
|
||||
|
||||
return results;
|
||||
return { invoices: results, businessesMissingTax: [...businessesMissingTax] };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { prisma } from "@/src/lib/prisma";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
|
||||
import { courseAmountCents, payoutAmountCents } from "@/src/lib/booking-amounts";
|
||||
|
||||
export type ZahlungStatus =
|
||||
| "paid"
|
||||
|
|
@ -32,12 +33,20 @@ export type ZahlungRow = {
|
|||
businessId: string;
|
||||
businessName: string;
|
||||
/**
|
||||
* Betrag, den das Business tatsächlich behält, in Cents. Bei
|
||||
* `partially_refunded` ist das der Nettobetrag (gezahlt − erstattet),
|
||||
* sonst der voll gezahlte Betrag. Die Rechnung selbst bleibt davon
|
||||
* unberührt (siehe invoice-actions.ts).
|
||||
* Kursbetrag in Cents, also der Umsatz des Studios vor Stripe-Gebühr. Bei
|
||||
* `partially_refunded` der Nettobetrag (gezahlt − erstattet). Die Rechnung
|
||||
* selbst bleibt davon unberührt (siehe invoice-actions.ts).
|
||||
*/
|
||||
amountPaid: number | null;
|
||||
/**
|
||||
* Stripe-Gebühr in Cents, die das Studio für diese Zahlung getragen hat.
|
||||
* null = Stripe hat die Zahlung noch nicht abgerechnet (wird über
|
||||
* charge.updated nachgetragen). Bei Altbuchungen trug die Plattform die
|
||||
* Gebühr, dort steht 0.
|
||||
*/
|
||||
stripeFeeCents: number | null;
|
||||
/** Was nach Plattform- und Stripe-Gebühr beim Studio ankommt, in Cents. */
|
||||
payoutCents: number | null;
|
||||
paymentStatus: string;
|
||||
invoiceNumber: string | null;
|
||||
};
|
||||
|
|
@ -74,6 +83,10 @@ export async function getZahlungen(
|
|||
createdAt: true,
|
||||
paymentStatus: true,
|
||||
amountPaid: true,
|
||||
platformFee: true,
|
||||
platformFeeOnTop: true,
|
||||
stripeFee: true,
|
||||
stripeAccountId: true,
|
||||
refundedAmount: true,
|
||||
invoiceNumber: true,
|
||||
course: {
|
||||
|
|
@ -99,10 +112,28 @@ export async function getZahlungen(
|
|||
// Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business
|
||||
// behält (netto). Voll erstattete / stornierte Zeilen behalten ihren
|
||||
// Rohbetrag – dort trägt das Status-Badge die Bedeutung.
|
||||
// Basis ist immer der Kursanteil: die Servicegebühr hat der Kunde an die
|
||||
// Plattform gezahlt, sie war nie Umsatz des Studios.
|
||||
const courseAmount = b.amountPaid !== null ? courseAmountCents(b) : null;
|
||||
const netAmount =
|
||||
b.paymentStatus === "partially_refunded" && b.amountPaid !== null
|
||||
? b.amountPaid - (b.refundedAmount ?? 0)
|
||||
: b.amountPaid;
|
||||
b.paymentStatus === "partially_refunded" && courseAmount !== null
|
||||
? courseAmount - (b.refundedAmount ?? 0)
|
||||
: courseAmount;
|
||||
|
||||
// Direct Charges (stripeAccountId gesetzt): die Gebühr zieht Stripe beim
|
||||
// Studio ab, null heißt "noch nicht abgerechnet". Altbuchungen: die
|
||||
// Plattform trug sie, für das Studio also 0.
|
||||
const stripeFeeCents = b.stripeAccountId ? b.stripeFee : (b.stripeFee ?? 0);
|
||||
|
||||
// Gleiche Konvention wie oben: nur Teilerstattungen werden abgezogen.
|
||||
// Die Gebühren bleiben auch bei erstatteten Zeilen stehen - Stripe
|
||||
// erstattet seine Gebühr nie, und die Plattformgebühr behält Kumasy bei
|
||||
// einer Stornierung ein.
|
||||
const payout =
|
||||
b.amountPaid !== null
|
||||
? payoutAmountCents(b) -
|
||||
(b.paymentStatus === "partially_refunded" ? (b.refundedAmount ?? 0) : 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
|
|
@ -114,6 +145,8 @@ export async function getZahlungen(
|
|||
businessId: b.course.business?.id ?? "",
|
||||
businessName: b.course.business?.name ?? "–",
|
||||
amountPaid: netAmount,
|
||||
stripeFeeCents,
|
||||
payoutCents: payout,
|
||||
paymentStatus: b.paymentStatus,
|
||||
invoiceNumber: b.invoiceNumber,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { requireManager } from "@/src/lib/auth/getUser";
|
|||
import { scopeBusinessIds } from "../lib/build-zahlungen-where";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { startOfMonth } from "date-fns";
|
||||
import { bookingRevenueSelect, sumCourseRevenueCents } from "@/src/lib/booking-amounts";
|
||||
|
||||
export type ZahlungMetricsFilters = {
|
||||
businessId?: string;
|
||||
|
|
@ -36,29 +37,30 @@ export async function getZahlungMetrics(
|
|||
async (): Promise<ZahlungMetrics> => {
|
||||
const monthStart = startOfMonth(new Date());
|
||||
|
||||
const [paidAgg, partialAgg, pendingGroups] = await Promise.all([
|
||||
prisma.courseBooking.aggregate({
|
||||
// Umsatz = Kurspreise, ohne die Servicegebühr, die der Kunde zusätzlich
|
||||
// an Kumasy gezahlt hat (siehe bookingRevenueSelect). Deshalb einzelne
|
||||
// Zeilen statt _sum über amountPaid.
|
||||
const [paidBookings, partialBookings, pendingGroups] = await Promise.all([
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { businessId: { in: scopedBusinessIds } },
|
||||
paymentStatus: "paid",
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
_sum: { amountPaid: true },
|
||||
_count: true,
|
||||
select: bookingRevenueSelect,
|
||||
}),
|
||||
// Teilerstattete Buchungen fließen mit ihrem NETTO-Betrag (amountPaid
|
||||
// Teilerstattete Buchungen fließen mit ihrem NETTO-Betrag (Kursanteil
|
||||
// minus refundedAmount) in den Umsatz ein - das spiegelt das Geld
|
||||
// wider, das tatsächlich beim Business verblieben ist, statt entweder
|
||||
// den vollen ursprünglichen Betrag zu zählen oder die Buchung ganz
|
||||
// aus der Summe zu lassen.
|
||||
prisma.courseBooking.aggregate({
|
||||
prisma.courseBooking.findMany({
|
||||
where: {
|
||||
course: { businessId: { in: scopedBusinessIds } },
|
||||
paymentStatus: "partially_refunded",
|
||||
createdAt: { gte: monthStart },
|
||||
},
|
||||
_sum: { amountPaid: true, refundedAmount: true },
|
||||
_count: true,
|
||||
select: bookingRevenueSelect,
|
||||
}),
|
||||
// Pro Kurs gruppieren statt eine Zeile pro Buchung zu laden: bei vielen
|
||||
// ausstehenden Buchungen für denselben Kurs bleibt die übertragene
|
||||
|
|
@ -90,14 +92,12 @@ export async function getZahlungMetrics(
|
|||
pendingAmount += Math.round(price * 100) * group._count._all;
|
||||
}
|
||||
|
||||
const partialNetRevenue =
|
||||
(partialAgg._sum.amountPaid ?? 0) - (partialAgg._sum.refundedAmount ?? 0);
|
||||
|
||||
return {
|
||||
revenueThisMonth: (paidAgg._sum.amountPaid ?? 0) + partialNetRevenue,
|
||||
revenueThisMonth:
|
||||
sumCourseRevenueCents(paidBookings) + sumCourseRevenueCents(partialBookings),
|
||||
pendingCount,
|
||||
pendingAmount,
|
||||
paidCount: paidAgg._count + partialAgg._count,
|
||||
paidCount: paidBookings.length + partialBookings.length,
|
||||
};
|
||||
},
|
||||
[`zahlungen-metrics-${manager.id}-${scopedBusinessIds.join(",")}`],
|
||||
|
|
|
|||
15
src/modules/zahlungen/lib/invoice-tax.ts
Normal file
15
src/modules/zahlungen/lib/invoice-tax.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Zerlegt einen Bruttobetrag in Nettobetrag und enthaltene Umsatzsteuer.
|
||||
*
|
||||
* Kurspreise sind Endpreise für Verbraucher, die Umsatzsteuer steckt also
|
||||
* bereits darin und wird herausgerechnet: Steuer = Brutto × Satz / (100 + Satz),
|
||||
* auf den Cent gerundet. Netto ergibt sich als Rest, damit Netto + Steuer immer
|
||||
* exakt den Bruttobetrag ergeben.
|
||||
*/
|
||||
export function splitGrossCents(
|
||||
grossCents: number,
|
||||
ratePercent: number
|
||||
): { netCents: number; taxCents: number } {
|
||||
const taxCents = Math.round((grossCents * ratePercent) / (100 + ratePercent));
|
||||
return { netCents: grossCents - taxCents, taxCents };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Document, Page, Text, View, Image, StyleSheet } from "@react-pdf/renderer";
|
||||
import type { InvoiceData } from "../../actions/invoice-actions";
|
||||
import { isAllowedLogoUrl } from "@/src/modules/settings/lib/logo-url";
|
||||
import { splitGrossCents } from "../../lib/invoice-tax";
|
||||
|
||||
/**
|
||||
* @react-pdf/renderer lädt Bilder zur Renderzeit serverseitig über die
|
||||
|
|
@ -100,13 +101,22 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
} = data;
|
||||
const hasRefund = refundedAmountCents > 0;
|
||||
|
||||
const customerAddressLine =
|
||||
!customer.isGuest && customer.street && customer.plz && customer.city
|
||||
? `${customer.street} ${customer.houseNumber ?? ""}, ${customer.plz} ${customer.city}`
|
||||
: null;
|
||||
|
||||
const hasLogo = includeLogo && isSafeImageUrl(business.logoUrl);
|
||||
|
||||
// Pflichtangaben nach § 14 Abs. 4 UStG bzw. § 33 UStDV: Bei Umsatzsteuer
|
||||
// Nettobetrag, Steuersatz und Steuerbetrag; sonst der Grund, warum keine
|
||||
// Umsatzsteuer berechnet wird. Gerechnet wird auf den Endbetrag nach einer
|
||||
// eventuellen Teilerstattung.
|
||||
const { tax } = business;
|
||||
const vatRate = tax.status === "regular" ? tax.vatRate : null;
|
||||
const vat = vatRate !== null ? splitGrossCents(amountCents, vatRate) : null;
|
||||
const taxNote =
|
||||
tax.status === "small_business"
|
||||
? "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet."
|
||||
: tax.status === "exempt"
|
||||
? tax.exemptionNote
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
|
|
@ -115,7 +125,8 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
<Text style={styles.businessName}>{business.name}</Text>
|
||||
<Text style={styles.muted}>{business.address}</Text>
|
||||
<Text style={styles.muted}>{business.email}</Text>
|
||||
{business.ustId ? <Text style={styles.muted}>USt-IdNr.: {business.ustId}</Text> : null}
|
||||
{tax.taxNumber ? <Text style={styles.muted}>Steuernummer: {tax.taxNumber}</Text> : null}
|
||||
{tax.ustId ? <Text style={styles.muted}>USt-IdNr.: {tax.ustId}</Text> : null}
|
||||
</View>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf/renderer's Image hat kein alt-Prop, nicht das DOM-<img> */}
|
||||
{hasLogo ? <Image src={business.logoUrl!} style={styles.logo} /> : null}
|
||||
|
|
@ -128,7 +139,11 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
<Text style={styles.label}>Rechnungsempfänger</Text>
|
||||
<Text style={styles.value}>{customer.name}</Text>
|
||||
<Text style={styles.value}>{customer.email}</Text>
|
||||
{customerAddressLine ? <Text style={styles.value}>{customerAddressLine}</Text> : null}
|
||||
{customer.addressLines.map((line) => (
|
||||
<Text key={line} style={styles.value}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.metaBlock}>
|
||||
<Text style={styles.label}>Rechnungsnummer</Text>
|
||||
|
|
@ -145,8 +160,8 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
</View>
|
||||
<View style={styles.tableRow}>
|
||||
<View style={styles.colDescription}>
|
||||
<Text>{course.name}</Text>
|
||||
<Text style={styles.muted}>Termin: {formatDate(course.date)}</Text>
|
||||
<Text>1 × {course.name}</Text>
|
||||
<Text style={styles.muted}>Leistungsdatum: {formatDate(course.date)}</Text>
|
||||
</View>
|
||||
<Text style={styles.colAmount}>{formatPrice(originalAmountCents)}</Text>
|
||||
</View>
|
||||
|
|
@ -165,6 +180,19 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{vat ? (
|
||||
<>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Nettobetrag</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(vat.netCents)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Umsatzsteuer {vatRate} %</Text>
|
||||
<Text style={styles.summaryValue}>{formatPrice(vat.taxCents)}</Text>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Gesamtbetrag</Text>
|
||||
<Text style={[styles.totalValue, { color: business.accentColor }]}>
|
||||
|
|
@ -172,11 +200,9 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
|
|||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={{ marginTop: 8, fontSize: 8, color: "#999999" }}>
|
||||
{business.ustId
|
||||
? "Alle Preise inkl. gesetzlicher Umsatzsteuer."
|
||||
: "Gemäß §19 UStG (Kleinunternehmerregelung) wird keine Umsatzsteuer berechnet."}
|
||||
</Text>
|
||||
{taxNote ? (
|
||||
<Text style={{ marginTop: 8, fontSize: 8, color: "#666666" }}>{taxNote}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.footer}>
|
||||
{business.banking ? (
|
||||
|
|
|
|||
100
src/modules/zahlungen/ui/components/stripe-payouts-embed.tsx
Normal file
100
src/modules/zahlungen/ui/components/stripe-payouts-embed.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { loadConnectAndInitialize } from "@stripe/connect-js";
|
||||
import {
|
||||
ConnectComponentsProvider,
|
||||
ConnectDisputesList,
|
||||
ConnectNotificationBanner,
|
||||
ConnectPayouts,
|
||||
} from "@stripe/react-connect-js";
|
||||
|
||||
/**
|
||||
* Kumasys Markenfarben (globals.css: --color-blue, --color-yellow) als feste
|
||||
* Werte - Stripe rendert in eigenen Frames und kennt unsere CSS-Variablen
|
||||
* nicht. Hauptaktionen wie in der App: dunkelblau mit gelber Schrift.
|
||||
*/
|
||||
const KUMASY_BLUE = "#1e293b";
|
||||
const KUMASY_YELLOW = "#f4c00c";
|
||||
|
||||
const appearance = {
|
||||
variables: {
|
||||
colorPrimary: KUMASY_BLUE,
|
||||
colorText: KUMASY_BLUE,
|
||||
colorSecondaryText: "#64748b",
|
||||
colorBackground: "#ffffff",
|
||||
colorBorder: "#e2e8f0",
|
||||
colorDanger: "#dc2626",
|
||||
actionPrimaryColorText: KUMASY_BLUE,
|
||||
buttonPrimaryColorBackground: KUMASY_BLUE,
|
||||
buttonPrimaryColorBorder: KUMASY_BLUE,
|
||||
buttonPrimaryColorText: KUMASY_YELLOW,
|
||||
buttonSecondaryColorBackground: "#ffffff",
|
||||
buttonSecondaryColorBorder: "#e2e8f0",
|
||||
buttonSecondaryColorText: KUMASY_BLUE,
|
||||
formAccentColor: KUMASY_BLUE,
|
||||
formHighlightColorBorder: KUMASY_YELLOW,
|
||||
borderRadius: "10px",
|
||||
buttonBorderRadius: "10px",
|
||||
formBorderRadius: "10px",
|
||||
fontSizeBase: "14px",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Stripes eingebettete Auszahlungs-Ansicht. Nur im Browser gerendert (siehe
|
||||
* stripe-payouts-panel.tsx), weil connect-js Stripes Skript nachlädt und
|
||||
* window braucht.
|
||||
*/
|
||||
export default function StripePayoutsEmbed({
|
||||
businessId,
|
||||
onError,
|
||||
}: {
|
||||
businessId: string;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [connectInstance] = useState(() =>
|
||||
loadConnectAndInitialize({
|
||||
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
// Stripe ruft das auch später erneut auf, wenn die Session abläuft.
|
||||
fetchClientSecret: async () => {
|
||||
const response = await fetch("/api/stripe/account-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ businessId }),
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!response.ok || !body?.clientSecret) {
|
||||
const message = body?.error ?? "Auszahlungen konnten nicht geladen werden.";
|
||||
onError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
return body.clientSecret;
|
||||
},
|
||||
appearance,
|
||||
locale: "de-DE",
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ConnectComponentsProvider connectInstance={connectInstance}>
|
||||
<div className="space-y-6">
|
||||
<ConnectNotificationBanner />
|
||||
<section className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm backdrop-blur-xl">
|
||||
<ConnectPayouts />
|
||||
</section>
|
||||
<section className="rounded-2xl border border-white/60 bg-white/70 p-6 shadow-sm backdrop-blur-xl">
|
||||
<h2 className="mb-1 text-base font-semibold text-gray-900">Zahlungsanfechtungen</h2>
|
||||
<p className="mb-4 text-xs text-gray-500">
|
||||
Ficht ein Kunde eine Zahlung bei seiner Bank an, erscheint sie hier. Antworte
|
||||
rechtzeitig, sonst wird der Betrag deinem Konto belastet.
|
||||
</p>
|
||||
<ConnectDisputesList />
|
||||
</section>
|
||||
</div>
|
||||
</ConnectComponentsProvider>
|
||||
);
|
||||
}
|
||||
27
src/modules/zahlungen/ui/components/stripe-payouts-panel.tsx
Normal file
27
src/modules/zahlungen/ui/components/stripe-payouts-panel.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Skeleton } from "@/src/components/ui/skeleton";
|
||||
|
||||
// Nur im Browser laden: connect-js braucht window und lädt Stripes Skript nach.
|
||||
const StripePayoutsEmbed = dynamic(() => import("./stripe-payouts-embed"), {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className="h-96 w-full rounded-2xl" />,
|
||||
});
|
||||
|
||||
export default function StripePayoutsPanel({ businessId }: { businessId: string }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// key: Beim Wechsel des Studios braucht Stripe eine neue Instanz für den
|
||||
// anderen Account.
|
||||
return <StripePayoutsEmbed key={businessId} businessId={businessId} onError={setError} />;
|
||||
}
|
||||
|
|
@ -9,7 +9,17 @@ import {
|
|||
TableRow,
|
||||
} from "@/src/components/ui/table";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Calendar, User, Dumbbell, Building2, Wallet, Tag, Download } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
User,
|
||||
Dumbbell,
|
||||
Building2,
|
||||
Wallet,
|
||||
Tag,
|
||||
Download,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
} from "lucide-react";
|
||||
import ZahlungStatusBadge from "./zahlungen-status-badge";
|
||||
import type { ZahlungRow } from "../../actions/zahlungen-actions";
|
||||
|
||||
|
|
@ -33,6 +43,8 @@ function formatDate(iso: string): string {
|
|||
interface ZahlungenTableProps {
|
||||
zahlungen: ZahlungRow[];
|
||||
showBusinessColumn: boolean;
|
||||
/** Studios ohne Steuerangaben: dort gibt es keinen Rechnungs-Download. */
|
||||
invoiceBlockedBusinessIds?: string[];
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
|
|
@ -41,11 +53,12 @@ interface ZahlungenTableProps {
|
|||
const ZahlungenTable = ({
|
||||
zahlungen,
|
||||
showBusinessColumn,
|
||||
invoiceBlockedBusinessIds = [],
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: ZahlungenTableProps) => {
|
||||
const columnCount = showBusinessColumn ? 7 : 6;
|
||||
const columnCount = showBusinessColumn ? 9 : 8;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden border border-white/40 bg-white/60 backdrop-blur-xl shadow-sm supports-backdrop-filter:bg-white/50">
|
||||
|
|
@ -84,6 +97,18 @@ const ZahlungenTable = ({
|
|||
Betrag
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden lg:table-cell font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<CreditCard size={14} className="text-gray-400" />
|
||||
Stripe-Gebühr
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Banknote size={14} className="text-gray-400" />
|
||||
Auszahlung
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-gray-700 pt-4 pb-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag size={14} className="text-gray-400" />
|
||||
|
|
@ -134,21 +159,36 @@ const ZahlungenTable = ({
|
|||
</TableCell>
|
||||
)}
|
||||
<TableCell>{formatPrice(z.amountPaid)}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-muted-foreground">
|
||||
{z.stripeFeeCents === null ? (
|
||||
// Stripe rechnet die Gebühr erst nach der Zahlung ab -
|
||||
// meist Sekunden, bei SEPA-Lastschrift auch Tage.
|
||||
<span title="Wird eingetragen, sobald Stripe die Zahlung abgerechnet hat.">
|
||||
steht noch aus
|
||||
</span>
|
||||
) : z.stripeFeeCents > 0 ? (
|
||||
`− ${formatPrice(z.stripeFeeCents)}`
|
||||
) : (
|
||||
formatPrice(0)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{formatPrice(z.payoutCents)}</TableCell>
|
||||
<TableCell>
|
||||
<ZahlungStatusBadge status={z.paymentStatus} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(z.paymentStatus === "paid" || z.paymentStatus === "partially_refunded") && (
|
||||
<a href={`/api/zahlungen/${z.id}/invoice`} download>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-white/60"
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{(z.paymentStatus === "paid" || z.paymentStatus === "partially_refunded") &&
|
||||
!invoiceBlockedBusinessIds.includes(z.businessId) && (
|
||||
<a href={`/api/zahlungen/${z.id}/invoice`} download>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full hover:bg-white/60"
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import ZahlungenMetrics from "../components/zahlungen-metrics";
|
||||
import ZahlungenFilterBar, { type ZahlungenFilterState } from "../components/zahlungen-filter-bar";
|
||||
|
|
@ -14,6 +15,8 @@ interface ZahlungenViewProps {
|
|||
initialCursor: string | null;
|
||||
initialMetrics: ZahlungMetrics;
|
||||
businesses: Array<{ id: string; name: string }>;
|
||||
/** Studios ohne Steuerangaben - für sie gibt es (noch) keine Rechnungen. */
|
||||
businessesMissingTax: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
// Freies Tippen im Suchfeld soll erst nach einer Tipp-Pause eine DB-Abfrage auslösen,
|
||||
|
|
@ -26,6 +29,7 @@ const ZahlungenView = ({
|
|||
initialCursor,
|
||||
initialMetrics,
|
||||
businesses,
|
||||
businessesMissingTax,
|
||||
}: ZahlungenViewProps) => {
|
||||
const [filters, setFilters] = useState<ZahlungenFilterState>({
|
||||
search: "",
|
||||
|
|
@ -128,10 +132,29 @@ const ZahlungenView = ({
|
|||
<div className="p-2">
|
||||
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-4xl font-bold">Zahlungen</h1>
|
||||
<Link
|
||||
href="/zahlungen/auszahlungen"
|
||||
className="inline-flex w-fit items-center rounded-lg border border-white/40 bg-white/60 px-4 py-2 text-sm font-medium backdrop-blur-xl hover:bg-white/80"
|
||||
>
|
||||
Auszahlungen & Bankverbindung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ZahlungenMetrics metrics={metrics} />
|
||||
|
||||
{businessesMissingTax.length > 0 && (
|
||||
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Rechnungen sind erst verfügbar, wenn deine Steuerangaben hinterlegt sind
|
||||
{businesses.length > 1
|
||||
? ` (fehlen bei: ${businessesMissingTax.map((b) => b.name).join(", ")})`
|
||||
: ""}
|
||||
.{" "}
|
||||
<Link href="/settings/business" className="font-medium underline">
|
||||
Jetzt hinterlegen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<ZahlungenFilterBar
|
||||
filters={filters}
|
||||
|
|
@ -145,6 +168,7 @@ const ZahlungenView = ({
|
|||
<ZahlungenTable
|
||||
zahlungen={zahlungen}
|
||||
showBusinessColumn={showBusinessColumn}
|
||||
invoiceBlockedBusinessIds={businessesMissingTax.map((b) => b.id)}
|
||||
hasMore={cursor !== null}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
export async function proxy(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request });
|
||||
|
||||
const supabase = createServerClient(
|
||||
|
|
@ -47,12 +47,10 @@ export async function middleware(request: NextRequest) {
|
|||
return redirectResponse;
|
||||
}
|
||||
|
||||
// manifest.json's start_url ("/?source=pwa") landet nur hier, wenn das
|
||||
// Betriebssystem die installierte PWA vom Homescreen öffnet - ein normaler
|
||||
// Browser-Aufruf von "/" hat diesen Parameter nie. Die Marketing-
|
||||
// Landingpage ist für Homescreen-Nutzer nutzlos (kein neuer Besucher würde
|
||||
// sie so erreichen), daher direkt zu Login/Dashboard weiterleiten.
|
||||
if (request.nextUrl.pathname === "/" && request.nextUrl.searchParams.get("source") === "pwa") {
|
||||
// "/" hat keine eigene Seite: eingeloggte Nutzer landen im Dashboard, alle
|
||||
// anderen beim Login. Das gilt auch für die installierte PWA, deren
|
||||
// start_url "/?source=pwa" ist - der Parameter wird dabei entfernt.
|
||||
if (request.nextUrl.pathname === "/") {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = user ? "/dashboard" : "/login";
|
||||
url.searchParams.delete("source");
|
||||
78
tests/api/stripe/account-session.test.ts
Normal file
78
tests/api/stripe/account-session.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/auth/getUser", () => ({ requireManager: vi.fn() }));
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: { business: { findUnique: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { accountSessions: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { POST } from "@/src/app/api/stripe/account-session/route";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
|
||||
function request(body: unknown) {
|
||||
return new Request("http://localhost/api/stripe/account-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as never;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireManager).mockResolvedValue({ id: "manager-1" } as never);
|
||||
vi.mocked(stripe.accountSessions.create).mockResolvedValue({
|
||||
client_secret: "accs_secret_1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/stripe/account-session", () => {
|
||||
it("gibt das Secret für das eigene Studio heraus", async () => {
|
||||
vi.mocked(prisma.business.findUnique).mockResolvedValue({
|
||||
managerId: "manager-1",
|
||||
stripeAccountId: "acct_studio",
|
||||
} as never);
|
||||
|
||||
const res = await POST(request({ businessId: "biz-1" }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ clientSecret: "accs_secret_1" });
|
||||
const params = vi.mocked(stripe.accountSessions.create).mock.calls[0][0];
|
||||
expect(params.account).toBe("acct_studio");
|
||||
// Erstattungen nur über Kumasys Stornierung - sonst wüsste die Buchung nichts davon.
|
||||
expect(params.components.disputes_list?.features?.refund_management).toBe(false);
|
||||
expect(params.components.payouts?.features?.external_account_collection).toBe(true);
|
||||
});
|
||||
|
||||
it("weist fremde Studios ab, ohne Stripe zu fragen", async () => {
|
||||
vi.mocked(prisma.business.findUnique).mockResolvedValue({
|
||||
managerId: "manager-2",
|
||||
stripeAccountId: "acct_fremd",
|
||||
} as never);
|
||||
|
||||
const res = await POST(request({ businessId: "biz-2" }));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(stripe.accountSessions.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("meldet ein fehlendes Stripe-Konto verständlich", async () => {
|
||||
vi.mocked(prisma.business.findUnique).mockResolvedValue({
|
||||
managerId: "manager-1",
|
||||
stripeAccountId: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(request({ businessId: "biz-1" }));
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).error).toMatch(/Stripe-Konto/);
|
||||
});
|
||||
|
||||
it("lehnt Anfragen ohne businessId ab", async () => {
|
||||
const res = await POST(request({}));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,14 +19,14 @@ vi.mock("@/src/lib/prisma", () => ({
|
|||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { checkout: { sessions: { create: vi.fn() } } },
|
||||
calculatePlatformFeeCents: vi.fn().mockReturnValue(100),
|
||||
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||||
eurosToCents: vi.fn().mockReturnValue(2900),
|
||||
}));
|
||||
|
||||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
||||
|
||||
let ipCounter = 0;
|
||||
|
||||
|
|
@ -127,3 +127,106 @@ describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Tic
|
|||
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/stripe/checkout/course – Servicegebühr", () => {
|
||||
it("schlägt die Gebühr als eigene Position auf und behält sie als application_fee ein", async () => {
|
||||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||||
vi.mocked(isUser).mockReturnValue(true);
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const [args, options] = vi.mocked(stripe.checkout.sessions.create).mock.calls[0] as never as [
|
||||
{
|
||||
line_items: { price_data: { unit_amount: number; product_data: { name: string } } }[];
|
||||
payment_intent_data: { application_fee_amount?: number; transfer_data?: unknown };
|
||||
metadata: Record<string, string>;
|
||||
},
|
||||
{ stripeAccount?: string },
|
||||
];
|
||||
|
||||
// Direct Charge: die Session entsteht auf dem Studio-Account, ohne
|
||||
// Transfer von der Plattform.
|
||||
expect(options).toEqual({ stripeAccount: "acct_123" });
|
||||
expect(args.payment_intent_data.transfer_data).toBeUndefined();
|
||||
|
||||
// Kurs zum vollen Preis + separate Gebührenposition
|
||||
expect(args.line_items).toHaveLength(2);
|
||||
expect(args.line_items[0].price_data.unit_amount).toBe(2900);
|
||||
expect(args.line_items[1].price_data.product_data.name).toBe("Servicegebühr");
|
||||
expect(args.line_items[1].price_data.unit_amount).toBe(50);
|
||||
|
||||
// Kumasy bekommt exakt die Gebühr als application_fee.
|
||||
expect(args.payment_intent_data.application_fee_amount).toBe(50);
|
||||
|
||||
// Der Webhook erkennt daran, dass die Gebühr obendrauf kam.
|
||||
expect(args.metadata.platformFeeModel).toBe("on_top");
|
||||
});
|
||||
|
||||
it("erzeugt keine Gebührenposition, wenn die Gebühr auf 0 konfiguriert ist", async () => {
|
||||
vi.mocked(getPlatformFeeCents).mockReturnValueOnce(0);
|
||||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||||
vi.mocked(isUser).mockReturnValue(true);
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||
line_items: unknown[];
|
||||
payment_intent_data: { application_fee_amount?: number };
|
||||
};
|
||||
expect(args.line_items).toHaveLength(1);
|
||||
// Ohne Gebühr wird gar keine application_fee gesetzt statt einer von 0.
|
||||
expect(args.payment_intent_data.application_fee_amount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
|
||||
function loggedIn() {
|
||||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||||
vi.mocked(isUser).mockReturnValue(true);
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||||
}
|
||||
|
||||
it("verlangt die Rechnungsadresse und trägt das Branding des Studios", async () => {
|
||||
loggedIn();
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||||
billing_address_collection: string;
|
||||
branding_settings: Record<string, unknown>;
|
||||
};
|
||||
expect(args.billing_address_collection).toBe("required");
|
||||
// Ohne eigenes Studio-Branding greifen die Kumasy-Farben.
|
||||
expect(args.branding_settings).toMatchObject({
|
||||
display_name: "Studio",
|
||||
background_color: "#1E293B",
|
||||
button_color: "#F4C00C",
|
||||
border_style: "rounded",
|
||||
});
|
||||
});
|
||||
|
||||
it("bucht ohne Branding weiter, wenn Stripe das Branding ablehnt", async () => {
|
||||
loggedIn();
|
||||
vi.mocked(stripe.checkout.sessions.create)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Invalid logo"), { param: "branding_settings[logo][url]" })
|
||||
)
|
||||
.mockResolvedValueOnce({ url: "https://checkout.stripe.com/ohne-branding" } as never);
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).url).toBe("https://checkout.stripe.com/ohne-branding");
|
||||
const retry = vi.mocked(stripe.checkout.sessions.create).mock.calls[1][0] as never as {
|
||||
branding_settings?: unknown;
|
||||
billing_address_collection: string;
|
||||
};
|
||||
expect(retry.branding_settings).toBeUndefined();
|
||||
expect(retry.billing_address_collection).toBe("required");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
281
tests/api/stripe/webhook.test.ts
Normal file
281
tests/api/stripe/webhook.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: {
|
||||
webhooks: { constructEvent: vi.fn() },
|
||||
paymentIntents: { retrieve: vi.fn() },
|
||||
},
|
||||
getWebhookSecrets: () => ["whsec_platform", "whsec_connect"],
|
||||
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
|
||||
}));
|
||||
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
|
||||
vi.mock("@/src/lib/stripe-fee", () => ({
|
||||
recordStripeFee: vi.fn(),
|
||||
recordStripeFeeForCharge: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
$transaction: vi.fn(),
|
||||
user: { findUnique: vi.fn(), findFirst: vi.fn() },
|
||||
course: { findUnique: vi.fn() },
|
||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
|
||||
courseWaitlistEntry: { deleteMany: vi.fn() },
|
||||
business: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn() }));
|
||||
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
|
||||
vi.mock("@/src/lib/mail/templates/booking-confirmation", () => ({
|
||||
generateBookingConfirmationEmail: () => ({ subject: "s", html: "h", text: "t" }),
|
||||
}));
|
||||
vi.mock("@/src/lib/mail/templates/booking-refund", () => ({
|
||||
generateBookingRefundEmail: () => ({ subject: "s", html: "h", text: "t" }),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({ toEmailBranding: () => ({}) }));
|
||||
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
|
||||
notifyBusinessManager: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
|
||||
isManagerOrEmployeeEmail: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||||
confirmDiscountReservation: vi.fn(),
|
||||
releaseDiscountReservation: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "@/src/app/api/stripe/webhook/route";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||||
import { recordStripeFee, recordStripeFeeForCharge } from "@/src/lib/stripe-fee";
|
||||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { releaseDiscountReservation } from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
// Die Datenbank-Transaktion der Kapazitätsprüfung.
|
||||
const tx = {
|
||||
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), count: vi.fn(), upsert: vi.fn() },
|
||||
course: { findUnique: vi.fn() },
|
||||
};
|
||||
|
||||
function request() {
|
||||
return new Request("http://localhost/api/stripe/webhook", {
|
||||
method: "POST",
|
||||
headers: { "stripe-signature": "t=1,v1=abc" },
|
||||
body: "{}",
|
||||
}) as never;
|
||||
}
|
||||
|
||||
/** Ein Event, das nur mit dem angegebenen Secret als gültig signiert gilt. */
|
||||
function deliver(event: object, signedWith = "whsec_platform") {
|
||||
vi.mocked(stripe.webhooks.constructEvent).mockImplementation((_body, _sig, secret) => {
|
||||
if (secret !== signedWith)
|
||||
throw new Error("No signatures found matching the expected signature");
|
||||
return event as never;
|
||||
});
|
||||
return POST(request());
|
||||
}
|
||||
|
||||
function sessionEvent(
|
||||
type: string,
|
||||
opts: { account?: string; paymentStatus?: string; metadata?: Record<string, string> } = {}
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
account: opts.account,
|
||||
data: {
|
||||
object: {
|
||||
id: "cs_1",
|
||||
payment_status: opts.paymentStatus ?? "paid",
|
||||
amount_total: 2950,
|
||||
payment_intent: "pi_1",
|
||||
customer_email: "kunde@example.com",
|
||||
customer_details: { email: "kunde@example.com", name: "Kunde" },
|
||||
metadata: {
|
||||
courseId: "course-1",
|
||||
userId: "user-1",
|
||||
isGuest: "false",
|
||||
platformFeeModel: "on_top",
|
||||
...opts.metadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((callback: (t: typeof tx) => unknown) =>
|
||||
callback(tx)) as never);
|
||||
tx.courseBooking.findUnique.mockResolvedValue(null);
|
||||
tx.courseBooking.findFirst.mockResolvedValue(null);
|
||||
tx.courseBooking.count.mockResolvedValue(0);
|
||||
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
|
||||
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
|
||||
|
||||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||||
application_fee_amount: 50,
|
||||
} as never);
|
||||
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
|
||||
vi.mocked(recordStripeFee).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user-1" } as never);
|
||||
vi.mocked(prisma.course.findUnique).mockResolvedValue({
|
||||
name: "Yoga",
|
||||
date: new Date("2099-01-01"),
|
||||
timeFrom: "10:00",
|
||||
timeTo: "11:00",
|
||||
coverImage: null,
|
||||
businessId: "biz-1",
|
||||
maxParticipants: 10,
|
||||
business: { name: "Studio", address: "Weg 1", branding: null },
|
||||
} as never);
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({ userId: "user-1" } as never);
|
||||
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: true,
|
||||
stripeRefundId: "re_1",
|
||||
amountRefundedCents: 2950,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stripe-Webhook – Signatur", () => {
|
||||
it("akzeptiert Events vom Connect-Endpoint mit dessen eigenem Secret", async () => {
|
||||
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_connect");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(stripe.webhooks.constructEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("lehnt ab, wenn kein Secret passt", async () => {
|
||||
const res = await deliver({ type: "customer.created", data: { object: {} } }, "whsec_fremd");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stripe-Webhook – Buchung", () => {
|
||||
it("bucht eine Direct Charge auf dem Studio-Account mit Gebühr obendrauf", async () => {
|
||||
const res = await deliver(
|
||||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||||
"whsec_connect"
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Die application_fee liegt am PaymentIntent des Studio-Accounts.
|
||||
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, {
|
||||
stripeAccount: "acct_studio",
|
||||
});
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||
paymentStatus: "paid",
|
||||
stripeSessionId: "cs_1",
|
||||
stripePaymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
amountPaid: 2950,
|
||||
platformFee: 50,
|
||||
platformFeeOnTop: true,
|
||||
});
|
||||
expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio");
|
||||
});
|
||||
|
||||
it("speichert die Rechnungsadresse aus dem Checkout an der Buchung", async () => {
|
||||
const event = sessionEvent("checkout.session.completed", { account: "acct_studio" });
|
||||
(event.data.object as Record<string, unknown>).customer_details = {
|
||||
email: "kunde@example.com",
|
||||
name: "Erika Mustermann",
|
||||
address: {
|
||||
line1: "Sandweg 91",
|
||||
line2: null,
|
||||
postal_code: "60316",
|
||||
city: "Frankfurt",
|
||||
country: "DE",
|
||||
},
|
||||
};
|
||||
|
||||
await deliver(event, "whsec_connect");
|
||||
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create.billingAddress).toEqual({
|
||||
name: "Erika Mustermann",
|
||||
line1: "Sandweg 91",
|
||||
line2: null,
|
||||
postalCode: "60316",
|
||||
city: "Frankfurt",
|
||||
country: "DE",
|
||||
});
|
||||
});
|
||||
|
||||
it("verbucht eine Session von vor der Umstellung als Altbuchung", async () => {
|
||||
// Prozentuale Gebühr, Destination Charge auf der Plattform: kein
|
||||
// event.account, kein platformFeeModel in den Metadaten.
|
||||
const event = sessionEvent("checkout.session.completed");
|
||||
delete (event.data.object.metadata as Record<string, string>).platformFeeModel;
|
||||
|
||||
await deliver(event);
|
||||
|
||||
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith("pi_1", undefined, undefined);
|
||||
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
|
||||
stripeAccountId: null,
|
||||
platformFeeOnTop: false,
|
||||
});
|
||||
// Bei Altbuchungen trug die Plattform die Stripe-Gebühr.
|
||||
expect(recordStripeFee).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bucht SEPA erst, wenn die verzögerte Zahlung angekommen ist", async () => {
|
||||
await deliver(
|
||||
sessionEvent("checkout.session.completed", {
|
||||
account: "acct_studio",
|
||||
paymentStatus: "unpaid",
|
||||
}),
|
||||
"whsec_connect"
|
||||
);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
|
||||
await deliver(
|
||||
sessionEvent("checkout.session.async_payment_succeeded", { account: "acct_studio" }),
|
||||
"whsec_connect"
|
||||
);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("erstattet eine Überbuchung auf dem Studio-Account und gibt die Gebühr ans Studio zurück", async () => {
|
||||
tx.courseBooking.count.mockResolvedValue(10);
|
||||
|
||||
await deliver(
|
||||
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
|
||||
"whsec_connect"
|
||||
);
|
||||
|
||||
expect(refundStripePayment).toHaveBeenCalledWith({
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
refundApplicationFee: true,
|
||||
});
|
||||
expect(recordStripeFee).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stripe-Webhook – nie bezahlt und Gebühren", () => {
|
||||
it("gibt bei gescheiterter SEPA-Zahlung die Rabatt-Reservierung frei", async () => {
|
||||
await deliver(
|
||||
sessionEvent("checkout.session.async_payment_failed", {
|
||||
account: "acct_studio",
|
||||
paymentStatus: "unpaid",
|
||||
metadata: { discountReservationToken: "tok_1" },
|
||||
}),
|
||||
"whsec_connect"
|
||||
);
|
||||
|
||||
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok_1");
|
||||
});
|
||||
|
||||
it("trägt die Stripe-Gebühr aus charge.updated nach - nur für Direct Charges", async () => {
|
||||
const charge = { payment_intent: "pi_1", balance_transaction: "txn_1" };
|
||||
|
||||
await deliver({ type: "charge.updated", account: "acct_studio", data: { object: charge } });
|
||||
expect(recordStripeFeeForCharge).toHaveBeenCalledWith(charge, "acct_studio");
|
||||
|
||||
vi.mocked(recordStripeFeeForCharge).mockClear();
|
||||
await deliver({ type: "charge.updated", data: { object: charge } });
|
||||
expect(recordStripeFeeForCharge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
85
tests/courses/booking-dialog.test.tsx
Normal file
85
tests/courses/booking-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("next/image", () => ({ default: () => null }));
|
||||
vi.mock("@/src/modules/courses/actions/booking-actions", () => ({
|
||||
checkUserBookingStatus: vi.fn(),
|
||||
previewDiscountCode: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/actions/waitlist-actions", () => ({
|
||||
joinWaitlistAsUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";
|
||||
import { checkUserBookingStatus } from "@/src/modules/courses/actions/booking-actions";
|
||||
|
||||
const course = {
|
||||
id: "course-1",
|
||||
name: "Spinning",
|
||||
sport: ["spinning"],
|
||||
level: "any",
|
||||
date: new Date("2099-01-01"),
|
||||
timeFrom: "07:30",
|
||||
timeTo: "08:30",
|
||||
maxParticipants: 10,
|
||||
price: 12,
|
||||
currentParticipants: 0,
|
||||
};
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<BookingDialog course={course} open onOpenChange={() => {}} serviceFeeCents={50} />
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("BookingDialog", () => {
|
||||
it("zeigt Gästen das Gast-Formular sofort, ohne zweiten Buchen-Button", async () => {
|
||||
vi.mocked(checkUserBookingStatus).mockResolvedValue({
|
||||
success: true,
|
||||
isLoggedIn: false,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText("Als Gast buchen")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Buchen" })).toBeNull();
|
||||
// Rabattcodes gibt es nur für Angemeldete.
|
||||
expect(screen.queryByPlaceholderText("Rabattcode")).toBeNull();
|
||||
});
|
||||
|
||||
it("zeigt Angemeldeten Buchen-Button und Rabattcode statt Gast-Formular", async () => {
|
||||
vi.mocked(checkUserBookingStatus).mockResolvedValue({
|
||||
success: true,
|
||||
isLoggedIn: true,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Buchen" })).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Rabattcode")).toBeTruthy();
|
||||
expect(screen.queryByText("Als Gast buchen")).toBeNull();
|
||||
});
|
||||
|
||||
it("zeigt den Endpreis inklusive Servicegebühr", async () => {
|
||||
vi.mocked(checkUserBookingStatus).mockResolvedValue({
|
||||
success: true,
|
||||
isLoggedIn: true,
|
||||
isBooked: false,
|
||||
isOnWaitlist: false,
|
||||
});
|
||||
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText("Preis: 12,50 €")).toBeTruthy();
|
||||
expect(screen.getByText(/12,00 €\s*Kurs \+ 0,50 € Servicegebühr/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
184
tests/courses/business-public-view.test.tsx
Normal file
184
tests/courses/business-public-view.test.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams() }));
|
||||
vi.mock("next/image", () => ({ default: () => null }));
|
||||
// Block (Desktop) und Zeitleiste (Handy) sind eigene Bausteine - hier zählt,
|
||||
// welche Kurse in welcher Woche bzw. an welchem Tag erscheinen.
|
||||
vi.mock("@/src/modules/courses/ui/components/CalendarEventBlock", () => ({
|
||||
default: ({ course }: { course: { name: string; timeFrom: string } }) => (
|
||||
<div data-testid="card">{`${course.timeFrom} ${course.name}`}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/ui/components/DayTimeline", () => ({
|
||||
default: ({ courses }: { courses: { id: string; name: string; timeFrom: string }[] }) => (
|
||||
<ol>
|
||||
{courses.map((course) => (
|
||||
<li key={course.id} data-testid="row">{`${course.timeFrom} ${course.name}`}</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/ui/components/BookingDialog", () => ({
|
||||
BookingDialog: () => null,
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/actions/booking-actions", () => ({
|
||||
getPublishedCoursesForBusiness: vi.fn(),
|
||||
}));
|
||||
|
||||
import BusinessPublicView from "@/src/modules/courses/ui/views/business-public-view";
|
||||
import { getPublishedCoursesForBusiness } from "@/src/modules/courses/actions/booking-actions";
|
||||
|
||||
const business = {
|
||||
id: "biz-1",
|
||||
name: "Since3",
|
||||
address: "Sandweg 91, 60316 Frankfurt",
|
||||
email: "studio@example.com",
|
||||
title: "Since3",
|
||||
slug: "since3",
|
||||
logoUrl: null,
|
||||
primaryColor: "#1e293b",
|
||||
accentColor: "#f4c00c",
|
||||
};
|
||||
|
||||
// Mitternacht Berliner Zeit, so wie Kurse in der DB liegen.
|
||||
const DAY = {
|
||||
thu: "2026-09-30T22:00:00Z", // heute, Donnerstag 1. Oktober
|
||||
fri: "2026-10-01T22:00:00Z",
|
||||
oct14: "2026-10-13T22:00:00Z",
|
||||
oct29: "2026-10-28T23:00:00Z",
|
||||
};
|
||||
|
||||
function course(id: string, name: string, date: string, timeFrom: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
sport: [name.toLowerCase().split(" ")[0]],
|
||||
level: "any",
|
||||
date: new Date(date),
|
||||
timeFrom,
|
||||
timeTo: "23:59",
|
||||
maxParticipants: 10,
|
||||
price: 12,
|
||||
currentParticipants: 3,
|
||||
};
|
||||
}
|
||||
|
||||
const thisWeek = [
|
||||
course("started", "Frueh", DAY.thu, "07:30"),
|
||||
course("tonight", "Yoga Flow", DAY.thu, "18:00"),
|
||||
course("fri", "Spinning", DAY.fri, "07:30"),
|
||||
];
|
||||
const later = [
|
||||
course("yoga", "Yoga", DAY.oct14, "18:00"),
|
||||
course("pilates", "Reformer Pilates", DAY.oct29, "18:00"),
|
||||
];
|
||||
|
||||
const window = { from: new Date("2026-09-30T08:00:00Z"), to: new Date("2026-10-31T08:00:00Z") };
|
||||
|
||||
function renderView(initialCourses = [...thisWeek, ...later], initialWindow = window) {
|
||||
return render(
|
||||
<BusinessPublicView
|
||||
business={business}
|
||||
initialCourses={initialCourses}
|
||||
initialWindow={initialWindow}
|
||||
serviceFeeCents={50}
|
||||
cancellationSummary="Kostenlos stornierbar bis Kursbeginn."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = () => screen.queryAllByTestId("card").map((c) => c.textContent);
|
||||
const rows = () => screen.queryAllByTestId("row").map((r) => r.textContent);
|
||||
|
||||
beforeEach(() => {
|
||||
// Donnerstag, 1. Oktober 2026, 10:00 Uhr in Berlin. Nur Date fälschen.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-01T08:00:00Z"));
|
||||
vi.mocked(getPublishedCoursesForBusiness).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Öffentliche Studio-Seite – Wochenkalender", () => {
|
||||
it("zeigt die aktuelle Woche ohne bereits begonnene Kurse", () => {
|
||||
renderView();
|
||||
expect(cards()).toEqual(["18:00 Yoga Flow", "07:30 Spinning"]);
|
||||
expect(screen.getByRole("button", { name: "Vorherige Woche" })).toHaveProperty(
|
||||
"disabled",
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("zeigt neue Kurse in zwei Wochen sofort - der Kalender startet in ihrer Woche", () => {
|
||||
// Der gemeldete Fall: Kurse am 14. und 29.10. waren hinter "Nächste 7 Tage" versteckt.
|
||||
renderView(later);
|
||||
expect(cards()).toEqual(["18:00 Yoga"]);
|
||||
});
|
||||
|
||||
it("führt aus einer leeren Woche zum nächsten Kurs", async () => {
|
||||
// Die Woche des 29.10. reicht über das geladene Fenster hinaus - nachladen.
|
||||
vi.mocked(getPublishedCoursesForBusiness).mockResolvedValue({
|
||||
success: true,
|
||||
courses: [],
|
||||
} as never);
|
||||
renderView(later);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Nächste Woche" }));
|
||||
expect(cards()).toEqual([]);
|
||||
expect(screen.getByText("In dieser Woche finden keine Kurse statt.")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nächste Kurse ab .*29\. Oktober/ }));
|
||||
expect((await screen.findAllByTestId("card")).map((c) => c.textContent)).toEqual([
|
||||
"18:00 Reformer Pilates",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zeigt am Handy die Kurse des gewählten Tages", () => {
|
||||
renderView();
|
||||
expect(rows()).toEqual(["18:00 Yoga Flow"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Morgen · Freitag/ }));
|
||||
expect(rows()).toEqual(["07:30 Spinning"]);
|
||||
});
|
||||
|
||||
it("zeigt in der Kartenansicht dieselbe Woche als Karten", () => {
|
||||
renderView();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Karten" }));
|
||||
|
||||
expect(cards()).toEqual([]);
|
||||
const titles = screen.getAllByRole("heading", { level: 4 }).map((h) => h.textContent);
|
||||
expect(titles).toEqual(["Yoga Flow", "Spinning"]);
|
||||
expect(screen.getByRole("region", { name: /^Morgen · Freitag/ })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Kalender" }));
|
||||
expect(cards()).toEqual(["18:00 Yoga Flow", "07:30 Spinning"]);
|
||||
});
|
||||
|
||||
it("lädt nach, wenn eine Woche hinter dem Geladenen liegt", () => {
|
||||
vi.mocked(getPublishedCoursesForBusiness).mockResolvedValue({
|
||||
success: true,
|
||||
courses: [],
|
||||
} as never);
|
||||
const shortWindow = { from: window.from, to: new Date("2026-10-08T08:00:00Z") };
|
||||
renderView(thisWeek, shortWindow);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Nächste Woche" }));
|
||||
|
||||
expect(getPublishedCoursesForBusiness).toHaveBeenCalledWith("biz-1", {
|
||||
from: shortWindow.to,
|
||||
to: new Date(shortWindow.to.getTime() + 30 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
});
|
||||
|
||||
it("nennt Stornoregel und Servicegebühr in einer Zeile", () => {
|
||||
renderView();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Kostenlos stornierbar bis Kursbeginn\.\s+Alle Preise inkl\. 0,50\s€ Servicegebühr\./
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
68
tests/courses/calendar-layout.test.ts
Normal file
68
tests/courses/calendar-layout.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isoWeekNumber,
|
||||
layoutDay,
|
||||
visibleHourRange,
|
||||
} from "@/src/modules/courses/lib/calendar-layout";
|
||||
import { colorForSports, sportColorMap } from "@/src/modules/courses/lib/sport-colors";
|
||||
|
||||
const c = (id: string, timeFrom: string, timeTo: string) => ({ id, timeFrom, timeTo });
|
||||
|
||||
describe("layoutDay", () => {
|
||||
it("stellt überlappende Kurse nebeneinander, getrennte untereinander", () => {
|
||||
const placed = layoutDay([
|
||||
c("yoga", "18:00", "19:00"),
|
||||
c("spin", "18:30", "19:30"),
|
||||
c("abend", "20:00", "21:00"),
|
||||
]);
|
||||
const byId = Object.fromEntries(placed.map((p) => [p.course.id, p]));
|
||||
|
||||
expect(byId.yoga).toMatchObject({ start: 1080, end: 1140, lane: 0, laneCount: 2 });
|
||||
expect(byId.spin).toMatchObject({ lane: 1, laneCount: 2 });
|
||||
// Kein Überlappen mit den beiden davor: volle Breite.
|
||||
expect(byId.abend).toMatchObject({ lane: 0, laneCount: 1 });
|
||||
});
|
||||
|
||||
it("nutzt eine frei gewordene Spalte wieder", () => {
|
||||
const placed = layoutDay([
|
||||
c("a", "09:00", "11:00"),
|
||||
c("b", "09:00", "10:00"),
|
||||
c("c", "10:00", "11:00"),
|
||||
]);
|
||||
const lanes = Object.fromEntries(placed.map((p) => [p.course.id, p.lane]));
|
||||
expect(lanes).toEqual({ a: 0, b: 1, c: 1 });
|
||||
expect(placed.every((p) => p.laneCount === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("nimmt ohne gültiges Ende eine Stunde an", () => {
|
||||
expect(layoutDay([c("x", "07:30", "07:30")])[0]).toMatchObject({ start: 450, end: 510 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleHourRange", () => {
|
||||
it("umfasst alle Kurse und mindestens acht Stunden", () => {
|
||||
expect(visibleHourRange([c("a", "18:00", "19:30")])).toEqual({ startHour: 12, endHour: 20 });
|
||||
expect(visibleHourRange([c("a", "06:30", "07:30"), c("b", "20:00", "21:15")])).toEqual({
|
||||
startHour: 6,
|
||||
endHour: 22,
|
||||
});
|
||||
expect(visibleHourRange([])).toEqual({ startHour: 8, endHour: 20 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isoWeekNumber", () => {
|
||||
it("berechnet die Kalenderwoche nach ISO", () => {
|
||||
expect(isoWeekNumber("2026-09-28")).toBe(40);
|
||||
expect(isoWeekNumber("2026-12-28")).toBe(53);
|
||||
expect(isoWeekNumber("2027-01-04")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sportColorMap", () => {
|
||||
it("gibt jeder Kursart stabil eine eigene Farbe", () => {
|
||||
const map = sportColorMap(["yoga", "spinning", "yoga", "pilates"]);
|
||||
expect(map.size).toBe(3);
|
||||
expect(new Set([...map.values()].map((color) => color.dot)).size).toBe(3);
|
||||
expect(colorForSports(["spinning"], map)).toBe(map.get("spinning"));
|
||||
});
|
||||
});
|
||||
152
tests/courses/cancel-booking-refund.test.ts
Normal file
152
tests/courses/cancel-booking-refund.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
courseBooking: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() },
|
||||
courseWaitlistEntry: { count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
guestLead: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
|
||||
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
|
||||
vi.mock("@/src/lib/mail/templates/booking-refund", () => ({
|
||||
generateBookingRefundEmail: vi.fn().mockReturnValue({ subject: "s", html: "h", text: "t" }),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/actions/notify-waitlist", () => ({ notifyCourseWaitlist: vi.fn() }));
|
||||
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
|
||||
notifyBusinessManager: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({
|
||||
toEmailBranding: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/actions/cancellation-policy", async (importOriginal) => {
|
||||
const actual = await importOriginal<object>();
|
||||
return { ...actual, calculateRefundAmountCents: vi.fn() };
|
||||
});
|
||||
|
||||
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
|
||||
import { calculateRefundAmountCents } from "@/src/modules/courses/actions/cancellation-policy";
|
||||
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
|
||||
import { notifyCourseWaitlist } from "@/src/modules/courses/actions/notify-waitlist";
|
||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||
|
||||
// Kunde zahlte 29,50 € = 29,00 € Kurs + 0,50 € Servicegebühr.
|
||||
const booking = {
|
||||
paymentStatus: "paid",
|
||||
amountPaid: 2950,
|
||||
platformFee: 50,
|
||||
platformFeeOnTop: true,
|
||||
stripePaymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
userId: "user-1",
|
||||
guestLeadId: null,
|
||||
course: {
|
||||
id: "course-1",
|
||||
name: "Yoga",
|
||||
date: new Date("2099-01-01"),
|
||||
timeFrom: "10:00",
|
||||
businessId: "biz-1",
|
||||
business: { name: "Studio", branding: null },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Ohne das leben die mock.calls des vorigen Tests weiter und die
|
||||
// Assertions auf calls[0] prüfen den falschen Aufruf.
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(booking as never);
|
||||
vi.mocked(prisma.courseBooking.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.courseBooking.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: "kunde@example.com" } as never);
|
||||
vi.mocked(prisma.courseWaitlistEntry.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(notifyCourseWaitlist).mockResolvedValue(undefined as never);
|
||||
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
|
||||
vi.mocked(generateBookingRefundEmail).mockReturnValue({
|
||||
subject: "s",
|
||||
html: "h",
|
||||
text: "t",
|
||||
} as never);
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: true,
|
||||
stripeRefundId: "re_1",
|
||||
amountRefundedCents: 2900,
|
||||
});
|
||||
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
|
||||
cancellationAllowed: true,
|
||||
refundPercent: 100,
|
||||
refundAmountCents: 2900,
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeBookingCancellation – Servicegebühr", () => {
|
||||
it("erstattet nur den Kursanteil und behält die Servicegebühr", async () => {
|
||||
const result = await executeBookingCancellation("booking-1", "user");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Bezugsgröße der Stornoquote ist der Kursanteil, nicht die Charge.
|
||||
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
|
||||
const { amountCents, refundApplicationFee, stripeAccountId } =
|
||||
vi.mocked(refundStripePayment).mock.calls[0][0];
|
||||
expect(amountCents).toBe(2900);
|
||||
expect(refundApplicationFee).toBe(false);
|
||||
// Direct Charge: erstattet wird auf dem Studio-Account, auf dem die Zahlung liegt.
|
||||
expect(stripeAccountId).toBe("acct_studio");
|
||||
});
|
||||
|
||||
it("gilt als vollständig erstattet, obwohl weniger als die Charge zurückging", async () => {
|
||||
await executeBookingCancellation("booking-1", "user");
|
||||
|
||||
// 2900 < amountPaid (2950) - ohne die Kursanteil-Bezugsgröße stünde hier
|
||||
// fälschlich "partially_refunded".
|
||||
expect(vi.mocked(prisma.courseBooking.update).mock.calls[0][0].data.paymentStatus).toBe(
|
||||
"refunded"
|
||||
);
|
||||
});
|
||||
|
||||
it("erstattet bei einer Kursabsage alles an den Kunden, gibt die Gebühr aber nicht ab", async () => {
|
||||
vi.mocked(refundStripePayment).mockResolvedValue({
|
||||
success: true,
|
||||
stripeRefundId: "re_2",
|
||||
amountRefundedCents: 2950,
|
||||
});
|
||||
|
||||
await executeBookingCancellation("booking-1", "manager", { forceFullRefund: true });
|
||||
|
||||
const { amountCents, refundApplicationFee } = vi.mocked(refundStripePayment).mock.calls[0][0];
|
||||
// undefined = komplette Charge inkl. Servicegebühr zurück an den Kunden.
|
||||
expect(amountCents).toBeUndefined();
|
||||
// Die Gebühr wandert NICHT ans Studio zurück: wer absagt, trägt die
|
||||
// Zahlungskosten seiner Absage.
|
||||
expect(refundApplicationFee).toBe(false);
|
||||
});
|
||||
|
||||
it("rechnet bei Altbuchungen ohne aufgeschlagene Gebühr unverändert", async () => {
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
|
||||
...booking,
|
||||
amountPaid: 2900,
|
||||
platformFee: 145,
|
||||
platformFeeOnTop: false,
|
||||
} as never);
|
||||
|
||||
await executeBookingCancellation("booking-1", "user");
|
||||
|
||||
// Alte Prozent-Fee: amountPaid IST der Kursanteil, nichts abziehen.
|
||||
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
|
||||
});
|
||||
|
||||
it("erstattet Altbuchungen über die Plattform (Destination Charge)", async () => {
|
||||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
|
||||
...booking,
|
||||
stripeAccountId: null,
|
||||
} as never);
|
||||
|
||||
await executeBookingCancellation("booking-1", "user");
|
||||
|
||||
// null schaltet refundStripePayment auf reverse_transfer um.
|
||||
expect(vi.mocked(refundStripePayment).mock.calls[0][0].stripeAccountId).toBeNull();
|
||||
});
|
||||
});
|
||||
35
tests/courses/cancellation-summary.test.ts
Normal file
35
tests/courses/cancellation-summary.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { describeCancellationPolicy } from "@/src/modules/courses/lib/cancellation-summary";
|
||||
|
||||
const base = { enabled: true, mode: "tiered" as const, fixedRefundPercent: 100, tiers: [] };
|
||||
|
||||
describe("describeCancellationPolicy", () => {
|
||||
it("beschreibt die Standardregel", () => {
|
||||
expect(describeCancellationPolicy(base)).toBe("Kostenlos stornierbar bis Kursbeginn.");
|
||||
});
|
||||
|
||||
it("beschreibt Staffeln in der Reihenfolge, in der sie greifen", () => {
|
||||
expect(
|
||||
describeCancellationPolicy({
|
||||
...base,
|
||||
tiers: [
|
||||
{ hoursBeforeStart: 6, refundPercent: 0 },
|
||||
{ hoursBeforeStart: 24, refundPercent: 50 },
|
||||
{ hoursBeforeStart: 12, refundPercent: 25 },
|
||||
],
|
||||
})
|
||||
).toBe(
|
||||
"Kostenlos stornierbar bis 24 Stunden vor Kursbeginn, danach 50 % Erstattung, " +
|
||||
"ab 12 Stunden vorher 25 % Erstattung, ab 6 Stunden vorher keine Erstattung."
|
||||
);
|
||||
});
|
||||
|
||||
it("beschreibt feste Sätze und deaktivierte Stornierung", () => {
|
||||
expect(describeCancellationPolicy({ ...base, mode: "fixed", fixedRefundPercent: 50 })).toBe(
|
||||
"Bei einer Stornierung werden 50 % erstattet."
|
||||
);
|
||||
expect(describeCancellationPolicy({ ...base, enabled: false })).toBe(
|
||||
"Gebuchte Kurse können nicht online storniert werden."
|
||||
);
|
||||
});
|
||||
});
|
||||
107
tests/courses/course-days.test.ts
Normal file
107
tests/courses/course-days.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
addDaysToKey,
|
||||
berlinDayEnd,
|
||||
berlinDayStart,
|
||||
dayKey,
|
||||
formatDayHeading,
|
||||
keyToLocalDate,
|
||||
localDateToKey,
|
||||
} from "@/src/modules/courses/lib/course-days";
|
||||
|
||||
describe("dayKey", () => {
|
||||
it("ordnet einen Kurs aus dem Datumsfeld seinem Tag zu", () => {
|
||||
// "2026-10-15" aus dem Formular landet als UTC-Mitternacht in der DB.
|
||||
expect(dayKey("2026-10-15T00:00:00.000Z")).toBe("2026-10-15");
|
||||
expect(dayKey("2026-12-01T01:00:00.000Z")).toBe("2026-12-01");
|
||||
});
|
||||
|
||||
it("ordnet einen auf Berliner Mitternacht datierten Kurs dem richtigen Tag zu", () => {
|
||||
// In UTC wäre es noch der 1. Oktober.
|
||||
expect(dayKey("2026-10-01T22:00:00.000Z")).toBe("2026-10-02");
|
||||
// Winterzeit: Mitternacht Berlin = 23:00 UTC am Vortag.
|
||||
expect(dayKey("2026-12-01T23:00:00.000Z")).toBe("2026-12-02");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addDaysToKey", () => {
|
||||
it("überspringt die Zeitumstellung ohne Tag doppelt oder gar nicht zu zählen", () => {
|
||||
// 25. Oktober 2026: Umstellung auf Winterzeit, der Tag hat 25 Stunden.
|
||||
expect(addDaysToKey("2026-10-24", 1)).toBe("2026-10-25");
|
||||
expect(addDaysToKey("2026-10-25", 1)).toBe("2026-10-26");
|
||||
expect(addDaysToKey("2026-03-28", 2)).toBe("2026-03-30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("berlinDayStart / berlinDayEnd", () => {
|
||||
it("liefert Mitternacht Berliner Zeit, im Sommer wie im Winter", () => {
|
||||
expect(berlinDayStart("2026-10-15").toISOString()).toBe("2026-10-14T22:00:00.000Z");
|
||||
expect(berlinDayStart("2026-12-01").toISOString()).toBe("2026-11-30T23:00:00.000Z");
|
||||
expect(berlinDayEnd("2026-10-15").toISOString()).toBe("2026-10-15T21:59:59.999Z");
|
||||
});
|
||||
|
||||
it("stimmt auch an den Tagen der Zeitumstellung", () => {
|
||||
// Umstellung jeweils um 01:00 UTC - die Mitternacht davor gilt noch die alte Zeit.
|
||||
expect(berlinDayStart("2026-10-25").toISOString()).toBe("2026-10-24T22:00:00.000Z");
|
||||
expect(berlinDayEnd("2026-10-25").toISOString()).toBe("2026-10-25T22:59:59.999Z");
|
||||
expect(berlinDayStart("2026-03-29").toISOString()).toBe("2026-03-28T23:00:00.000Z");
|
||||
expect(berlinDayEnd("2026-03-29").toISOString()).toBe("2026-03-29T21:59:59.999Z");
|
||||
});
|
||||
|
||||
it("schließt einen Kurs am letzten Tag des Fensters ein", () => {
|
||||
// Der gemeldete Fall: heute 1.10., Fenster bis einschließlich 15.10. - der
|
||||
// Kurs liegt als 2026-10-15T00:00Z in der DB und fiel bisher heraus.
|
||||
const course = new Date("2026-10-15T00:00:00.000Z");
|
||||
const windowTo = berlinDayEnd(addDaysToKey("2026-10-01", 14));
|
||||
expect(course <= windowTo).toBe(true);
|
||||
// "Mehr laden" beginnt direkt danach, ohne Lücke.
|
||||
expect(new Date(windowTo.getTime() + 1)).toEqual(berlinDayStart("2026-10-16"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDayHeading", () => {
|
||||
it("benennt heute und morgen", () => {
|
||||
expect(formatDayHeading("2026-10-01", "2026-10-01")).toMatch(/^Heute · Donnerstag/);
|
||||
expect(formatDayHeading("2026-10-02", "2026-10-01")).toMatch(/^Morgen · Freitag/);
|
||||
expect(formatDayHeading("2026-10-30", "2026-10-01")).toBe("Freitag, 30. Oktober");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kalender-Umrechnung", () => {
|
||||
it("führt einen Tag verlustfrei hin und zurück", () => {
|
||||
expect(localDateToKey(keyToLocalDate("2026-10-30"))).toBe("2026-10-30");
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
durationMinutes,
|
||||
startOfWeekKey,
|
||||
weekDayKeys,
|
||||
} from "@/src/modules/courses/lib/course-days";
|
||||
|
||||
describe("Wochen", () => {
|
||||
it("beginnt montags - auch wenn heute Sonntag ist", () => {
|
||||
expect(startOfWeekKey("2026-10-01")).toBe("2026-09-28"); // Donnerstag
|
||||
expect(startOfWeekKey("2026-10-04")).toBe("2026-09-28"); // Sonntag
|
||||
expect(startOfWeekKey("2026-10-05")).toBe("2026-10-05"); // Montag
|
||||
});
|
||||
|
||||
it("liefert sieben Tage über die Zeitumstellung hinweg", () => {
|
||||
expect(weekDayKeys("2026-10-19")).toEqual([
|
||||
"2026-10-19",
|
||||
"2026-10-20",
|
||||
"2026-10-21",
|
||||
"2026-10-22",
|
||||
"2026-10-23",
|
||||
"2026-10-24",
|
||||
"2026-10-25",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("durationMinutes", () => {
|
||||
it("berechnet die Dauer und verwirft Unsinn", () => {
|
||||
expect(durationMinutes("07:30", "08:45")).toBe(75);
|
||||
expect(durationMinutes("08:00", "07:00")).toBeNull();
|
||||
});
|
||||
});
|
||||
60
tests/lib/billing-address.test.ts
Normal file
60
tests/lib/billing-address.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
billingAddressFromCheckout,
|
||||
formatBillingAddressLines,
|
||||
readBillingAddress,
|
||||
} from "@/src/lib/billing-address";
|
||||
|
||||
const details = {
|
||||
name: "Erika Mustermann",
|
||||
email: "erika@example.com",
|
||||
address: {
|
||||
line1: "Sandweg 91",
|
||||
line2: null,
|
||||
postal_code: "60316",
|
||||
city: "Frankfurt",
|
||||
country: "DE",
|
||||
state: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("billingAddressFromCheckout", () => {
|
||||
it("übernimmt Name und Anschrift aus dem Checkout", () => {
|
||||
expect(billingAddressFromCheckout(details as never)).toEqual({
|
||||
name: "Erika Mustermann",
|
||||
line1: "Sandweg 91",
|
||||
line2: null,
|
||||
postalCode: "60316",
|
||||
city: "Frankfurt",
|
||||
country: "DE",
|
||||
});
|
||||
});
|
||||
|
||||
it("liefert null, wenn die Anschrift unvollständig ist", () => {
|
||||
expect(
|
||||
billingAddressFromCheckout({
|
||||
...details,
|
||||
address: { ...details.address, city: null },
|
||||
} as never)
|
||||
).toBeNull();
|
||||
expect(billingAddressFromCheckout(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBillingAddress", () => {
|
||||
it("verwirft gespeicherte Werte, denen Pflichtteile fehlen", () => {
|
||||
expect(readBillingAddress({ line1: "Sandweg 91", city: "Frankfurt" })).toBeNull();
|
||||
expect(readBillingAddress(null)).toBeNull();
|
||||
expect(readBillingAddress("Sandweg 91")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBillingAddressLines", () => {
|
||||
it("lässt Deutschland weg und nennt andere Länder", () => {
|
||||
const de = billingAddressFromCheckout(details as never)!;
|
||||
expect(formatBillingAddressLines(de)).toEqual(["Sandweg 91", "60316 Frankfurt"]);
|
||||
expect(
|
||||
formatBillingAddressLines({ ...de, country: "AT", postalCode: "1010", city: "Wien" })
|
||||
).toEqual(["Sandweg 91", "1010 Wien", "Österreich"]);
|
||||
});
|
||||
});
|
||||
87
tests/lib/booking-amounts.test.ts
Normal file
87
tests/lib/booking-amounts.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
courseAmountCents,
|
||||
payoutAmountCents,
|
||||
sumCourseRevenueCents,
|
||||
} from "@/src/lib/booking-amounts";
|
||||
|
||||
describe("courseAmountCents", () => {
|
||||
it("zieht die Gebühr ab, wenn sie dem Kunden aufgeschlagen wurde", () => {
|
||||
// Kunde zahlte 29,50 €, davon 0,50 € Servicegebühr an die Plattform.
|
||||
expect(courseAmountCents({ amountPaid: 2950, platformFee: 50, platformFeeOnTop: true })).toBe(
|
||||
2900
|
||||
);
|
||||
});
|
||||
|
||||
it("lässt Altbuchungen mit prozentualer Fee unverändert", () => {
|
||||
// Kunde zahlte 29,00 €, davon wurden 5 % aus dem Kurspreis einbehalten -
|
||||
// amountPaid ist hier bereits der Kursanteil und darf nicht erneut
|
||||
// gekürzt werden, sonst ändern sich bestehende Rechnungen rückwirkend.
|
||||
expect(courseAmountCents({ amountPaid: 2900, platformFee: 145, platformFeeOnTop: false })).toBe(
|
||||
2900
|
||||
);
|
||||
});
|
||||
|
||||
it("behandelt fehlende Beträge als 0", () => {
|
||||
expect(courseAmountCents({ amountPaid: null, platformFee: null, platformFeeOnTop: true })).toBe(
|
||||
0
|
||||
);
|
||||
expect(courseAmountCents({ amountPaid: 2950, platformFee: null, platformFeeOnTop: true })).toBe(
|
||||
2950
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumCourseRevenueCents", () => {
|
||||
it("summiert Kursanteile über beide Gebührenmodelle und zieht Erstattungen ab", () => {
|
||||
const revenue = sumCourseRevenueCents([
|
||||
{ amountPaid: 2950, platformFee: 50, platformFeeOnTop: true, refundedAmount: null },
|
||||
{ amountPaid: 2900, platformFee: 145, platformFeeOnTop: false, refundedAmount: null },
|
||||
// Teilerstattung: 50 % des Kursanteils gingen zurück.
|
||||
{ amountPaid: 2950, platformFee: 50, platformFeeOnTop: true, refundedAmount: 1450 },
|
||||
]);
|
||||
expect(revenue).toBe(2900 + 2900 + (2900 - 1450));
|
||||
});
|
||||
|
||||
it("ist bei leerer Liste 0", () => {
|
||||
expect(sumCourseRevenueCents([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("payoutAmountCents", () => {
|
||||
it("zieht bei Direct Charges Plattform- und Stripe-Gebühr ab", () => {
|
||||
// Kunde zahlte 29,50 €; 0,50 € gingen an Kumasy, 0,69 € an Stripe.
|
||||
expect(
|
||||
payoutAmountCents({
|
||||
amountPaid: 2950,
|
||||
platformFee: 50,
|
||||
platformFeeOnTop: true,
|
||||
stripeFee: 69,
|
||||
})
|
||||
).toBe(2831);
|
||||
});
|
||||
|
||||
it("zieht bei Altbuchungen die prozentuale Gebühr vom Kurspreis ab", () => {
|
||||
// Dem Studio gingen 5 % ab, die Stripe-Gebühr trug die Plattform. Den
|
||||
// Kursanteil (2900) als Auszahlung zu zeigen, wäre 1,45 € zu viel.
|
||||
expect(
|
||||
payoutAmountCents({
|
||||
amountPaid: 2900,
|
||||
platformFee: 145,
|
||||
platformFeeOnTop: false,
|
||||
stripeFee: null,
|
||||
})
|
||||
).toBe(2755);
|
||||
});
|
||||
|
||||
it("zeigt bis zur Abrechnung durch Stripe den Betrag ohne Stripe-Gebühr", () => {
|
||||
expect(
|
||||
payoutAmountCents({
|
||||
amountPaid: 2950,
|
||||
platformFee: 50,
|
||||
platformFeeOnTop: true,
|
||||
stripeFee: null,
|
||||
})
|
||||
).toBe(2900);
|
||||
});
|
||||
});
|
||||
116
tests/lib/stripe-fee.test.ts
Normal file
116
tests/lib/stripe-fee.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: {
|
||||
paymentIntents: { retrieve: vi.fn() },
|
||||
balanceTransactions: { retrieve: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: { courseBooking: { updateMany: vi.fn(), count: vi.fn() } },
|
||||
}));
|
||||
|
||||
import {
|
||||
recordStripeFee,
|
||||
recordStripeFeeForCharge,
|
||||
stripeProcessingFeeCents,
|
||||
} from "@/src/lib/stripe-fee";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
// Echte Form vom Test-Account: fee=171 enthält BEIDE Abzüge.
|
||||
const balanceTransaction = {
|
||||
fee: 171,
|
||||
fee_details: [
|
||||
{ type: "stripe_fee", amount: 121 },
|
||||
{ type: "application_fee", amount: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.courseBooking.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
});
|
||||
|
||||
describe("stripeProcessingFeeCents", () => {
|
||||
it("zählt unsere application_fee nicht als Stripe-Gebühr", () => {
|
||||
// Mit bt.fee stünden hier 171 - 50 Cent zu viel im Zahlungen-Tab.
|
||||
expect(stripeProcessingFeeCents(balanceTransaction as never)).toBe(121);
|
||||
});
|
||||
|
||||
it("rechnet weitere Stripe-Abzüge wie Steuern mit", () => {
|
||||
const withTax = {
|
||||
fee_details: [...balanceTransaction.fee_details, { type: "tax", amount: 23 }],
|
||||
};
|
||||
expect(stripeProcessingFeeCents(withTax as never)).toBe(144);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordStripeFee", () => {
|
||||
it("liest die Gebühr auf dem Studio-Account und speichert sie nur einmal", async () => {
|
||||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||||
latest_charge: { balance_transaction: balanceTransaction },
|
||||
} as never);
|
||||
|
||||
await recordStripeFee("pi_1", "acct_studio");
|
||||
|
||||
expect(stripe.paymentIntents.retrieve).toHaveBeenCalledWith(
|
||||
"pi_1",
|
||||
{ expand: ["latest_charge.balance_transaction"] },
|
||||
{ stripeAccount: "acct_studio" }
|
||||
);
|
||||
expect(prisma.courseBooking.updateMany).toHaveBeenCalledWith({
|
||||
// stripeFee: null - ein zweites Event darf den Wert nicht überschreiben.
|
||||
where: { stripePaymentIntentId: "pi_1", stripeFee: null },
|
||||
data: { stripeFee: 121 },
|
||||
});
|
||||
});
|
||||
|
||||
it("tut nichts, solange Stripe die Zahlung nicht abgerechnet hat", async () => {
|
||||
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
|
||||
latest_charge: { balance_transaction: null },
|
||||
} as never);
|
||||
|
||||
await recordStripeFee("pi_1", "acct_studio");
|
||||
|
||||
expect(prisma.courseBooking.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordStripeFeeForCharge", () => {
|
||||
const charge = { payment_intent: "pi_1", balance_transaction: "txn_1" };
|
||||
|
||||
it("trägt die Gebühr nach, wenn eine Buchung auf sie wartet", async () => {
|
||||
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(stripe.balanceTransactions.retrieve).mockResolvedValue(balanceTransaction as never);
|
||||
|
||||
await recordStripeFeeForCharge(charge as never, "acct_studio");
|
||||
|
||||
expect(stripe.balanceTransactions.retrieve).toHaveBeenCalledWith("txn_1", undefined, {
|
||||
stripeAccount: "acct_studio",
|
||||
});
|
||||
expect(prisma.courseBooking.updateMany).toHaveBeenCalledWith({
|
||||
where: { stripePaymentIntentId: "pi_1", stripeFee: null },
|
||||
data: { stripeFee: 121 },
|
||||
});
|
||||
});
|
||||
|
||||
it("fragt Stripe gar nicht erst, wenn keine Buchung wartet", async () => {
|
||||
// charge.updated feuert auch bei Erstattungen - dann ist die Gebühr längst da.
|
||||
vi.mocked(prisma.courseBooking.count).mockResolvedValue(0 as never);
|
||||
|
||||
await recordStripeFeeForCharge(charge as never, "acct_studio");
|
||||
|
||||
expect(stripe.balanceTransactions.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignoriert Charges ohne Abrechnung", async () => {
|
||||
await recordStripeFeeForCharge(
|
||||
{ payment_intent: "pi_1", balance_transaction: null } as never,
|
||||
"acct_studio"
|
||||
);
|
||||
|
||||
expect(prisma.courseBooking.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
56
tests/lib/stripe-refund.test.ts
Normal file
56
tests/lib/stripe-refund.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { refunds: { create: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { refundStripePayment } from "@/src/lib/stripe-refund";
|
||||
import { stripe } from "@/src/lib/stripe";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(stripe.refunds.create).mockResolvedValue({ id: "re_1", amount: 2900 } as never);
|
||||
});
|
||||
|
||||
describe("refundStripePayment", () => {
|
||||
it("erstattet eine Direct Charge auf dem Studio-Account, ohne reverse_transfer", async () => {
|
||||
const result = await refundStripePayment({
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
amountCents: 2900,
|
||||
idempotencyKey: "refund-b1",
|
||||
refundApplicationFee: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, stripeRefundId: "re_1", amountRefundedCents: 2900 });
|
||||
expect(stripe.refunds.create).toHaveBeenCalledWith(
|
||||
{ payment_intent: "pi_1", amount: 2900, refund_application_fee: false },
|
||||
{ stripeAccount: "acct_studio", idempotencyKey: "refund-b1" }
|
||||
);
|
||||
});
|
||||
|
||||
it("holt das Geld bei Altbuchungen per reverse_transfer vom Studio zurück", async () => {
|
||||
await refundStripePayment({
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: null,
|
||||
refundApplicationFee: true,
|
||||
});
|
||||
|
||||
expect(stripe.refunds.create).toHaveBeenCalledWith(
|
||||
{ payment_intent: "pi_1", refund_application_fee: true, reverse_transfer: true },
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it("meldet einen Stripe-Fehler als Ergebnis statt zu werfen", async () => {
|
||||
vi.mocked(stripe.refunds.create).mockRejectedValue(new Error("insufficient balance"));
|
||||
|
||||
const result = await refundStripePayment({
|
||||
paymentIntentId: "pi_1",
|
||||
stripeAccountId: "acct_studio",
|
||||
refundApplicationFee: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "insufficient balance" });
|
||||
});
|
||||
});
|
||||
36
tests/lib/stripe.test.ts
Normal file
36
tests/lib/stripe.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { getWebhookSecrets, onAccount } from "@/src/lib/stripe";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("getWebhookSecrets", () => {
|
||||
it("liefert Plattform- und Connect-Secret, wenn beide gesetzt sind", () => {
|
||||
vi.stubEnv("STRIPE_WEBHOOK_SECRET", "whsec_platform");
|
||||
vi.stubEnv("STRIPE_CONNECT_WEBHOOK_SECRET", "whsec_connect");
|
||||
expect(getWebhookSecrets()).toEqual(["whsec_platform", "whsec_connect"]);
|
||||
});
|
||||
|
||||
it("kommt lokal mit dem einen CLI-Secret aus", () => {
|
||||
vi.stubEnv("STRIPE_WEBHOOK_SECRET", "whsec_cli");
|
||||
vi.stubEnv("STRIPE_CONNECT_WEBHOOK_SECRET", "");
|
||||
expect(getWebhookSecrets()).toEqual(["whsec_cli"]);
|
||||
});
|
||||
|
||||
it("wirft, wenn kein Secret gesetzt ist", () => {
|
||||
vi.stubEnv("STRIPE_WEBHOOK_SECRET", "");
|
||||
vi.stubEnv("STRIPE_CONNECT_WEBHOOK_SECRET", " ");
|
||||
expect(() => getWebhookSecrets()).toThrow("STRIPE_WEBHOOK_SECRET");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onAccount", () => {
|
||||
it("adressiert Direct Charges auf dem Studio-Account", () => {
|
||||
expect(onAccount("acct_studio")).toEqual({ stripeAccount: "acct_studio" });
|
||||
});
|
||||
|
||||
it("lässt Altbuchungen auf dem Plattformkonto", () => {
|
||||
expect(onAccount(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
118
tests/onboarding/onboarding-wizard-view.test.tsx
Normal file
118
tests/onboarding/onboarding-wizard-view.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import OnboardingWizardView from "@/src/modules/onboarding/ui/views/onboarding-wizard-view";
|
||||
import {
|
||||
ONBOARDING_STEPS,
|
||||
WIZARD_STEP_IDS,
|
||||
type OnboardingStepId,
|
||||
} from "@/src/modules/onboarding/lib/onboarding-steps";
|
||||
|
||||
// Server Actions und Next-Navigation wegmocken - hier geht es ausschließlich um
|
||||
// die Render-Stabilität des Wizards beim Durchklicken, nicht um die Fachlogik.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/actions/business-branding-actions", () => ({
|
||||
saveBusinessBranding: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/actions/cancellation-settings-actions", () => ({
|
||||
saveCancellationPolicy: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/actions/stripe-connect-actions", () => ({
|
||||
createConnectOnboardingLink: vi.fn(async () => ({ success: true, url: "https://stripe.test" })),
|
||||
}));
|
||||
vi.mock("@/src/modules/rooms/actions/room-actions", () => ({
|
||||
createRoom: vi.fn(async () => ({ success: true, message: "ok" })),
|
||||
}));
|
||||
vi.mock("@/src/modules/courses/actions/course-actions", () => ({
|
||||
toggleBusinessPublic: vi.fn(async () => ({ success: true, slug: "test-studio" })),
|
||||
}));
|
||||
|
||||
const allOpen = Object.fromEntries(
|
||||
Object.keys(ONBOARDING_STEPS).map((id) => [id, false])
|
||||
) as Record<OnboardingStepId, boolean>;
|
||||
|
||||
const defaultProps = {
|
||||
businessId: "b1",
|
||||
businessName: "Test Studio",
|
||||
managerFirstName: "Alex",
|
||||
done: allOpen,
|
||||
branding: {
|
||||
companyName: "Test Studio",
|
||||
logoUrl: null,
|
||||
primaryColor: "#1d3557",
|
||||
accentColor: "#c9a84c",
|
||||
},
|
||||
policy: { tiers: [] },
|
||||
isPublic: false,
|
||||
slug: null,
|
||||
stripeAccountStatus: null,
|
||||
initialStep: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// jsdom kennt window.scrollTo nicht - der Wizard scrollt bei jedem Schritt nach oben.
|
||||
window.scrollTo = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("OnboardingWizardView - Render-Stabilität", () => {
|
||||
it("klickt ohne Hook-Fehler vom Willkommens-Screen bis zum Abschluss durch", async () => {
|
||||
const user = userEvent.setup();
|
||||
// Ein Hook-Order-Verstoß ("Rendered more hooks than during the previous render")
|
||||
// wirft React während des Renders - der Test schlägt dann hier fehl.
|
||||
render(<OnboardingWizardView {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Hallo Alex/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Los geht/ }));
|
||||
|
||||
// Jeder Wizard-Schritt einmal vorwärts
|
||||
for (let i = 0; i < WIZARD_STEP_IDS.length; i++) {
|
||||
expect(
|
||||
screen.getByText(`Schritt ${i + 1} von ${WIZARD_STEP_IDS.length}`)
|
||||
).toBeInTheDocument();
|
||||
const nextLabel = i === WIZARD_STEP_IDS.length - 1 ? /^Fertig$/ : /Weiter|Überspringen/;
|
||||
await user.click(screen.getByRole("button", { name: nextLabel }));
|
||||
}
|
||||
|
||||
expect(screen.getByText(/Grundeinrichtung erledigt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("übersteht Vor- und Zurücknavigation zwischen allen Screens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OnboardingWizardView {...defaultProps} initialStep="publicPage" />);
|
||||
|
||||
// Direkteinstieg beim letzten Schritt (wie beim Rücksprung von Stripe)
|
||||
expect(
|
||||
screen.getByText(`Schritt ${WIZARD_STEP_IDS.length} von ${WIZARD_STEP_IDS.length}`)
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Über die Punkte-Navigation quer durch alle Schritte springen und wieder zurück
|
||||
for (const id of WIZARD_STEP_IDS) {
|
||||
await user.click(screen.getByRole("button", { name: ONBOARDING_STEPS[id].label }));
|
||||
}
|
||||
for (const id of [...WIZARD_STEP_IDS].reverse()) {
|
||||
await user.click(screen.getByRole("button", { name: ONBOARDING_STEPS[id].label }));
|
||||
}
|
||||
|
||||
expect(screen.getByText("Schritt 1 von " + WIZARD_STEP_IDS.length)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wechselt zwischen Abschluss-Screen und letztem Schritt hin und her", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OnboardingWizardView {...defaultProps} initialStep="publicPage" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Fertig$/ }));
|
||||
expect(screen.getByText(/Grundeinrichtung erledigt/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Zurück$/ }));
|
||||
expect(
|
||||
screen.getByText(`Schritt ${WIZARD_STEP_IDS.length} von ${WIZARD_STEP_IDS.length}`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
98
tests/settings/tax-settings.test.ts
Normal file
98
tests/settings/tax-settings.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
readTaxSettings,
|
||||
toTaxColumns,
|
||||
validateTaxSettings,
|
||||
} from "@/src/modules/settings/lib/tax-settings";
|
||||
|
||||
describe("validateTaxSettings", () => {
|
||||
it("verlangt bei Umsatzsteuerpflicht Steuersatz und eine Steuer-ID", () => {
|
||||
expect(validateTaxSettings({ taxStatus: "regular", vatRate: 19 })).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(
|
||||
validateTaxSettings({ taxStatus: "regular", vatRate: 16, taxNumber: "12/345/67890" })
|
||||
).toMatchObject({ ok: false });
|
||||
expect(
|
||||
validateTaxSettings({ taxStatus: "regular", vatRate: 19, taxNumber: "12/345/67890" })
|
||||
).toEqual({
|
||||
ok: true,
|
||||
settings: { status: "regular", vatRate: 19, taxNumber: "12/345/67890", ustId: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("akzeptiert eine reine Steuernummer - das war früher fälschlich ein Kleinunternehmer", () => {
|
||||
const result = validateTaxSettings({
|
||||
taxStatus: "regular",
|
||||
vatRate: 19,
|
||||
taxNumber: "12/345/67890",
|
||||
ustId: "",
|
||||
});
|
||||
expect(result.ok && result.settings.status).toBe("regular");
|
||||
});
|
||||
|
||||
it("lässt Kleinunternehmer ohne Steuer-ID zu", () => {
|
||||
expect(validateTaxSettings({ taxStatus: "small_business" })).toEqual({
|
||||
ok: true,
|
||||
settings: { status: "small_business", taxNumber: null, ustId: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("verlangt bei Steuerbefreiung den Grund, der auf der Rechnung steht", () => {
|
||||
expect(validateTaxSettings({ taxStatus: "exempt", taxNumber: "12/345/67890" })).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(
|
||||
validateTaxSettings({
|
||||
taxStatus: "exempt",
|
||||
taxNumber: "12/345/67890",
|
||||
taxExemptionNote: "Steuerfrei nach § 4 Nr. 21 UStG",
|
||||
})
|
||||
).toMatchObject({ ok: true, settings: { exemptionNote: "Steuerfrei nach § 4 Nr. 21 UStG" } });
|
||||
});
|
||||
|
||||
it("normalisiert die USt-IdNr. und lehnt Unsinn ab", () => {
|
||||
const ok = validateTaxSettings({ taxStatus: "small_business", ustId: " de 123 456 789 " });
|
||||
expect(ok.ok && ok.settings.ustId).toBe("DE123456789");
|
||||
expect(validateTaxSettings({ taxStatus: "small_business", ustId: "123" })).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt unbekannte Status ab", () => {
|
||||
expect(validateTaxSettings({ taxStatus: "gemischt" })).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readTaxSettings", () => {
|
||||
const empty = {
|
||||
taxStatus: null,
|
||||
vatRate: null,
|
||||
taxNumber: null,
|
||||
ustId: null,
|
||||
taxExemptionNote: null,
|
||||
};
|
||||
|
||||
it("liefert null, solange nichts hinterlegt ist - auch nicht bei vorhandener USt-IdNr.", () => {
|
||||
expect(readTaxSettings(empty)).toBeNull();
|
||||
expect(readTaxSettings({ ...empty, ustId: "DE123456789" })).toBeNull();
|
||||
});
|
||||
|
||||
it("liefert null für einen gespeicherten, aber unvollständigen Stand", () => {
|
||||
expect(readTaxSettings({ ...empty, taxStatus: "regular", vatRate: 19 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTaxColumns", () => {
|
||||
it("leert Felder, die zum gewählten Status nicht gehören", () => {
|
||||
expect(
|
||||
toTaxColumns({ status: "small_business", taxNumber: "12/345/67890", ustId: null })
|
||||
).toEqual({
|
||||
taxStatus: "small_business",
|
||||
vatRate: null,
|
||||
taxNumber: "12/345/67890",
|
||||
ustId: null,
|
||||
taxExemptionNote: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
tests/zahlungen/invoice-actions.test.ts
Normal file
129
tests/zahlungen/invoice-actions.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
courseBooking: { findFirst: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { assembleInvoiceData } from "@/src/modules/zahlungen/actions/invoice-actions";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
const manager = { businesses: [{ id: "biz-1" }] } as never;
|
||||
|
||||
function bookingWithTax(tax: Record<string, unknown>, billingAddress: unknown = null) {
|
||||
return {
|
||||
billingAddress,
|
||||
amountPaid: 2950,
|
||||
platformFee: 50,
|
||||
platformFeeOnTop: true,
|
||||
refundedAmount: null,
|
||||
paymentStatus: "paid",
|
||||
course: {
|
||||
name: "Yoga",
|
||||
date: new Date("2099-01-01"),
|
||||
price: 29,
|
||||
business: {
|
||||
id: "biz-1",
|
||||
name: "Studio",
|
||||
address: "Weg 1, 12345 Stadt",
|
||||
email: "studio@example.com",
|
||||
ustId: null,
|
||||
taxStatus: null,
|
||||
vatRate: null,
|
||||
taxNumber: null,
|
||||
taxExemptionNote: null,
|
||||
banking: null,
|
||||
branding: null,
|
||||
...tax,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
name: "Kunde",
|
||||
email: "k@example.com",
|
||||
street: null,
|
||||
houseNumber: null,
|
||||
plz: null,
|
||||
city: null,
|
||||
},
|
||||
guestLead: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("assembleInvoiceData – Steuerangaben", () => {
|
||||
it("vergibt ohne Steuerangaben KEINE Rechnungsnummer", async () => {
|
||||
vi.mocked(prisma.courseBooking.findFirst).mockResolvedValue(bookingWithTax({}) as never);
|
||||
|
||||
const result = await assembleInvoiceData("booking-1", manager);
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: "missing_tax_settings" });
|
||||
// Die Nummernvergabe läuft in einer Transaktion - sie darf gar nicht erst
|
||||
// starten, sonst entsteht eine Lücke im Nummernkreis.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behandelt eine vorhandene USt-IdNr. ohne Status nicht als Steuerangabe", async () => {
|
||||
vi.mocked(prisma.courseBooking.findFirst).mockResolvedValue(
|
||||
bookingWithTax({ ustId: "DE123456789" }) as never
|
||||
);
|
||||
|
||||
const result = await assembleInvoiceData("booking-1", manager);
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: "missing_tax_settings" });
|
||||
});
|
||||
|
||||
it("erstellt die Rechnung mit Steuersatz, wenn alles hinterlegt ist", async () => {
|
||||
vi.mocked(prisma.courseBooking.findFirst).mockResolvedValue(
|
||||
bookingWithTax({ taxStatus: "regular", vatRate: 19, taxNumber: "12/345/67890" }) as never
|
||||
);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue({
|
||||
invoiceNumber: "INV-1",
|
||||
issuedAt: new Date("2026-10-01"),
|
||||
} as never);
|
||||
|
||||
const result = await assembleInvoiceData("booking-1", manager);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.data.business.tax).toEqual({
|
||||
status: "regular",
|
||||
vatRate: 19,
|
||||
taxNumber: "12/345/67890",
|
||||
ustId: null,
|
||||
});
|
||||
// Die Studio-Rechnung zeigt nur den Kurspreis, nicht die Servicegebühr.
|
||||
expect(result.data.amountCents).toBe(2900);
|
||||
});
|
||||
|
||||
it("adressiert die Rechnung an die Anschrift aus dem Checkout", async () => {
|
||||
vi.mocked(prisma.courseBooking.findFirst).mockResolvedValue(
|
||||
bookingWithTax(
|
||||
{ taxStatus: "small_business" },
|
||||
{
|
||||
name: "Erika Mustermann",
|
||||
line1: "Sandweg 91",
|
||||
line2: null,
|
||||
postalCode: "60316",
|
||||
city: "Frankfurt",
|
||||
country: "DE",
|
||||
}
|
||||
) as never
|
||||
);
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue({
|
||||
invoiceNumber: "INV-2",
|
||||
issuedAt: new Date("2026-10-01"),
|
||||
} as never);
|
||||
|
||||
const result = await assembleInvoiceData("booking-1", manager);
|
||||
|
||||
expect(result.ok && result.data.customer).toMatchObject({
|
||||
name: "Erika Mustermann",
|
||||
addressLines: ["Sandweg 91", "60316 Frankfurt"],
|
||||
});
|
||||
});
|
||||
});
|
||||
20
tests/zahlungen/invoice-tax.test.ts
Normal file
20
tests/zahlungen/invoice-tax.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { splitGrossCents } from "@/src/modules/zahlungen/lib/invoice-tax";
|
||||
|
||||
describe("splitGrossCents", () => {
|
||||
it("rechnet 19 % aus einem Endpreis heraus", () => {
|
||||
// 29,00 € brutto = 24,37 € netto + 4,63 € USt
|
||||
expect(splitGrossCents(2900, 19)).toEqual({ netCents: 2437, taxCents: 463 });
|
||||
});
|
||||
|
||||
it("rechnet 7 % heraus", () => {
|
||||
expect(splitGrossCents(2900, 7)).toEqual({ netCents: 2710, taxCents: 190 });
|
||||
});
|
||||
|
||||
it("ergibt immer exakt den Bruttobetrag", () => {
|
||||
for (const gross of [1, 99, 1250, 1999, 25000]) {
|
||||
const { netCents, taxCents } = splitGrossCents(gross, 19);
|
||||
expect(netCents + taxCents).toBe(gross);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
tests/zahlungen/zahlungen-metrics.test.ts
Normal file
54
tests/zahlungen/zahlungen-metrics.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/src/lib/auth/getUser", () => ({
|
||||
requireManager: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
courseBooking: { findMany: vi.fn(), groupBy: vi.fn() },
|
||||
course: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
// Cache ausschalten - der Test soll die Rechnung sehen, nicht den Cache.
|
||||
vi.mock("next/cache", () => ({
|
||||
unstable_cache: (fn: () => unknown) => fn,
|
||||
}));
|
||||
|
||||
import { getZahlungMetrics } from "@/src/modules/zahlungen/actions/zahlungen-metrics-actions";
|
||||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireManager).mockResolvedValue({
|
||||
id: "manager-1",
|
||||
businesses: [{ id: "biz-1" }],
|
||||
} as never);
|
||||
vi.mocked(prisma.courseBooking.groupBy).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("getZahlungMetrics – Umsatz diesen Monat", () => {
|
||||
it("zählt die Servicegebühr nicht als Umsatz des Studios", async () => {
|
||||
vi.mocked(prisma.courseBooking.findMany)
|
||||
// bezahlt
|
||||
.mockResolvedValueOnce([
|
||||
// Neues Modell: Kunde zahlte 29,50 €, davon 0,50 € an Kumasy.
|
||||
{ amountPaid: 2950, platformFee: 50, platformFeeOnTop: true, refundedAmount: null },
|
||||
// Altes 5-%-Modell: amountPaid ist bereits der Kurspreis.
|
||||
{ amountPaid: 2900, platformFee: 145, platformFeeOnTop: false, refundedAmount: null },
|
||||
] as never)
|
||||
// teilerstattet: 50 % des Kursanteils zurück
|
||||
.mockResolvedValueOnce([
|
||||
{ amountPaid: 2950, platformFee: 50, platformFeeOnTop: true, refundedAmount: 1450 },
|
||||
] as never);
|
||||
|
||||
const result = await getZahlungMetrics({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
// 29,00 + 29,00 + (29,00 − 14,50). Mit amountPaid wären es 0,50 € mehr pro
|
||||
// Buchung im neuen Modell gewesen.
|
||||
expect(result.metrics.revenueThisMonth).toBe(2900 + 2900 + 1450);
|
||||
expect(result.metrics.paidCount).toBe(3);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue