changed to direct charge and changed designs

This commit is contained in:
Rajpreet0 2026-10-01 23:28:32 +02:00
parent b5703aaf8e
commit 55d8838c0e
86 changed files with 5854 additions and 3349 deletions

617
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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}

View 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>
);
}

View file

@ -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>
);

View 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 }
);
}
}

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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.`,

View file

@ -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}
/>
);
}

View file

@ -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>
);

File diff suppressed because it is too large Load diff

View file

@ -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 (

View 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;
}

View 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
View 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);
}

View file

@ -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,

View file

@ -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
*/

View file

@ -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,
};

View file

@ -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

View file

@ -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,

View file

@ -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,
},
};
}

View file

@ -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) {

View file

@ -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,

View 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);
}

View 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(", ")}.`;
}

View 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());
}

View 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" },
};

View 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}`);
}
}

View 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];
}

View file

@ -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;

View 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}
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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 }> = {

View 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>
);
}

View 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;
}

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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>

View file

@ -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}

View file

@ -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(),
}));

View file

@ -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;

View file

@ -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";

View file

@ -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." };
}
}

View 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,
};
}

View file

@ -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"

View 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;

View file

@ -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>
);

View file

@ -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] };
}
/**

View file

@ -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,
};

View file

@ -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(",")}`],

View 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 };
}

View file

@ -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 ? (

View 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>
);
}

View 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} />;
}

View file

@ -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>
))

View file

@ -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 &amp; 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}

View file

@ -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");

View 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);
});
});

View file

@ -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");
});
});

View 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();
});
});

View 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();
});
});

View 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();
});
});

View 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"));
});
});

View 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();
});
});

View 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."
);
});
});

View 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();
});
});

View 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"]);
});
});

View 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);
});
});

View 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();
});
});

View 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
View 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();
});
});

View 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();
});
});

View 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,
});
});
});

View 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"],
});
});
});

View 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);
}
});
});

View 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);
});
});