diff --git a/.env.example b/.env.example
index 00e2d7f..1f46c90 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,8 @@ APP_DEBUG=0
APP_URL=http://localhost:8080
APP_TIMEZONE=Europe/Berlin
APP_KEY=
+ALLOW_PUBLIC_REGISTRATION=0
+RFID_INGEST_ENABLED=0
DB_HOST=127.0.0.1
DB_PORT=3306
@@ -12,4 +14,21 @@ DB_USER=root
DB_PASS=
MAIL_FROM=noreply@example.com
+MAIL_REPLY_TO=
+MAIL_SUBJECT_PREFIX="[Kaffeekasse] "
+MAIL_LOG_FALLBACK=1
+MAIL_LOG_FILE=
+
+PASSWORD_RESET_URL=http://localhost:8080/reset-password?token={{token}}
+PASSWORD_RESET_TTL_MINUTES=60
+PASSWORD_RESET_MAX_ACTIVE_TOKENS=3
+PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"
+
+RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5
+RATE_LIMIT_LOGIN_WINDOW_SECONDS=900
+RATE_LIMIT_LOGIN_BLOCK_SECONDS=900
+RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3
+RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600
+RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600
+
RFID_SHARED_SECRET=change-me
diff --git a/app/Controllers/PlatformController.php b/app/Controllers/PlatformController.php
index 34ec734..d4549e0 100644
--- a/app/Controllers/PlatformController.php
+++ b/app/Controllers/PlatformController.php
@@ -7,8 +7,12 @@ use App\Core\Csrf;
use App\Core\Database;
use App\Core\Response;
use App\Core\Session;
+use App\Core\TenantContext;
use App\Core\View;
use App\Services\AuditService;
+use App\Services\MailerService;
+use App\Services\PasswordResetService;
+use App\Services\RateLimiterService;
use App\Services\SetupService;
use App\Services\TenantRegistrationService;
use RuntimeException;
@@ -47,6 +51,7 @@ final class PlatformController
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
'setupComplete' => $setup->isInstalled(),
'stats' => $stats,
+ 'allowPublicRegistration' => $this->config['allow_public_registration'] ?? false,
]);
}
@@ -86,6 +91,11 @@ final class PlatformController
Response::redirect('/install');
}
+ if (!$this->allowsPublicRegistration()) {
+ $this->session->flash('error', 'Die offene Registrierung ist derzeit deaktiviert. Fuer einen Pilotzugang bitte direkt den Betreiber kontaktieren.');
+ Response::redirect('/');
+ }
+
$this->view->render('home/register', [
'title' => 'Mandant registrieren',
]);
@@ -96,13 +106,31 @@ final class PlatformController
$this->assertCsrf();
try {
+ if (!$this->allowsPublicRegistration()) {
+ throw new RuntimeException('Die offene Registrierung ist derzeit deaktiviert.');
+ }
+
$pdo = $this->database->pdo();
+ $rateLimiter = $this->rateLimiter($pdo);
+ $email = mb_strtolower(trim((string) ($_POST['owner_email'] ?? '')));
+ $scopes = $this->buildScopes('registration', $email);
+
+ $this->ensureAllowed($rateLimiter, 'registration', $scopes);
+
$audit = new AuditService($pdo);
$service = new TenantRegistrationService($pdo, $audit);
$slug = $service->register($_POST);
+ $this->clearScopes($rateLimiter, 'registration', $scopes);
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
Response::redirect('/t/' . rawurlencode($slug) . '/login');
} catch (\Throwable $throwable) {
+ if ($this->database->isConfigured()) {
+ try {
+ $this->registerFailure('registration', mb_strtolower(trim((string) ($_POST['owner_email'] ?? ''))));
+ } catch (\Throwable) {
+ }
+ }
+
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
Response::redirect('/register');
@@ -122,17 +150,25 @@ final class PlatformController
try {
$pdo = $this->database->pdo();
+ $email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
+ $rateLimiter = $this->rateLimiter($pdo);
+ $scopes = $this->buildScopes('login', 'platform|' . $email);
+
+ $this->ensureAllowed($rateLimiter, 'login', $scopes);
+
$auth = new Auth($pdo, $this->session);
$audit = new AuditService($pdo);
$success = $auth->attemptPlatformLogin((string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
$audit->log(null, null, 'platform.login', 'user', null, $success ? 'success' : 'failure', [
- 'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
+ 'email' => $email,
]);
if (!$success) {
+ $this->hitScopes($rateLimiter, 'login', $scopes);
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
}
+ $this->clearScopes($rateLimiter, 'login', $scopes);
$this->session->flash('success', 'Willkommen im Plattformbereich.');
Response::redirect('/admin');
} catch (\Throwable $throwable) {
@@ -142,6 +178,108 @@ final class PlatformController
}
}
+ public function passwordResetRequestForm(): void
+ {
+ $scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
+ $tenant = $this->passwordResetTenant($scope, $tenantSlug);
+
+ $this->view->render('auth/reset-request', [
+ 'title' => 'Passwort vergessen',
+ 'scope' => $scope,
+ 'tenantSlug' => $tenantSlug,
+ 'tenant' => $tenant,
+ ]);
+ }
+
+ public function passwordResetRequestSubmit(): void
+ {
+ $this->assertCsrf();
+
+ try {
+ $pdo = $this->database->pdo();
+ $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
+ $tenant = $this->passwordResetTenant($scope, $tenantSlug);
+ $service = new PasswordResetService(
+ $pdo,
+ new MailerService($this->config['mail'] ?? []),
+ $this->config['password_reset'] ?? []
+ );
+ $service->requestReset((string) ($_POST['email'] ?? ''), $this->passwordResetContext($scope, $tenant, $tenantSlug));
+ $this->session->flash('success', 'Falls ein Konto existiert, wurde ein Link zum Zuruecksetzen versendet.');
+ Response::redirect($this->passwordResetRequestUrl($scope, $tenant, $tenantSlug));
+ } catch (\Throwable $throwable) {
+ remember_old_input($_POST);
+ $this->session->flash('error', $throwable->getMessage());
+ $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
+ Response::redirect($this->passwordResetRequestUrl($scope, null, $tenantSlug));
+ }
+ }
+
+ public function passwordResetConfirmForm(): void
+ {
+ $scope = $this->normalizePasswordResetScope((string) ($_GET['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_GET['tenant'] ?? ''));
+ $tenant = $this->passwordResetTenant($scope, $tenantSlug);
+ $token = (string) ($_GET['token'] ?? '');
+ $resetToken = null;
+
+ if ($this->database->isConfigured() && trim($token) !== '') {
+ try {
+ $resetToken = $this->passwordResetService($this->database->pdo())->findValidToken($token);
+ } catch (\Throwable) {
+ $resetToken = null;
+ }
+ }
+
+ $this->view->render('auth/reset-confirm', [
+ 'title' => 'Neues Passwort setzen',
+ 'scope' => $scope,
+ 'tenantSlug' => $tenantSlug,
+ 'tenant' => $tenant,
+ 'token' => $token,
+ 'resetToken' => $resetToken,
+ ]);
+ }
+
+ public function passwordResetConfirmSubmit(): void
+ {
+ $this->assertCsrf();
+
+ try {
+ $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
+ $tenant = $this->passwordResetTenant($scope, $tenantSlug);
+ $password = (string) ($_POST['password'] ?? '');
+ $passwordConfirm = (string) ($_POST['password_confirm'] ?? '');
+
+ if ($password !== $passwordConfirm) {
+ throw new RuntimeException('Die Passwort-Bestaetigung stimmt nicht ueberein.');
+ }
+
+ $pdo = $this->database->pdo();
+ $service = $this->passwordResetService($pdo);
+ $userId = $service->resetPassword(
+ (string) ($_POST['token'] ?? ''),
+ $password
+ );
+
+ (new AuditService($pdo))->log(null, $userId, 'password.reset', 'user', $userId, 'success');
+
+ $this->session->flash('success', 'Das Passwort wurde aktualisiert. Du kannst dich jetzt anmelden.');
+ Response::redirect($this->passwordResetLoginUrl($scope, $tenant, $tenantSlug));
+ } catch (\Throwable $throwable) {
+ remember_old_input($_POST);
+ $this->session->flash('error', $throwable->getMessage());
+ $token = trim((string) ($_POST['token'] ?? ''));
+ $scope = $this->normalizePasswordResetScope((string) ($_POST['scope'] ?? 'platform'));
+ $tenantSlug = trim((string) ($_POST['tenant_slug'] ?? ''));
+ Response::redirect($this->passwordResetConfirmUrl($token, $scope, null, $tenantSlug));
+ }
+ }
+
public function adminDashboard(): void
{
$pdo = $this->database->pdo();
@@ -186,4 +324,135 @@ final class PlatformController
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
}
}
+
+ private function allowsPublicRegistration(): bool
+ {
+ return (bool) ($this->config['allow_public_registration'] ?? false);
+ }
+
+ private function rateLimiter(\PDO $pdo): RateLimiterService
+ {
+ return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
+ }
+
+ private function passwordResetService(\PDO $pdo): PasswordResetService
+ {
+ return new PasswordResetService(
+ $pdo,
+ new MailerService($this->config['mail'] ?? []),
+ $this->config['password_reset'] ?? []
+ );
+ }
+
+ private function buildScopes(string $action, string $identifier = ''): array
+ {
+ $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+ $scopes = [$action . '|ip|' . $ip];
+
+ if (trim($identifier) !== '') {
+ $scopes[] = $action . '|subject|' . mb_strtolower(trim($identifier));
+ }
+
+ return $scopes;
+ }
+
+ private function ensureAllowed(RateLimiterService $rateLimiter, string $action, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $state = $rateLimiter->inspect($action, $scope);
+
+ if (!$state['allowed']) {
+ throw new RuntimeException(
+ 'Zu viele Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
+ );
+ }
+ }
+ }
+
+ private function hitScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $rateLimiter->hit($action, $scope);
+ }
+ }
+
+ private function clearScopes(RateLimiterService $rateLimiter, string $action, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $rateLimiter->clear($action, $scope);
+ }
+ }
+
+ private function registerFailure(string $action, string $identifier = ''): void
+ {
+ $pdo = $this->database->pdo();
+ $rateLimiter = $this->rateLimiter($pdo);
+
+ foreach ($this->buildScopes($action, $identifier) as $scope) {
+ $rateLimiter->hit($action, $scope);
+ }
+ }
+
+ private function normalizePasswordResetScope(string $scope): string
+ {
+ return trim($scope) === 'tenant' ? 'tenant' : 'platform';
+ }
+
+ private function passwordResetTenant(string $scope, string $tenantSlug): ?array
+ {
+ $tenantSlug = trim($tenantSlug);
+
+ if ($scope !== 'tenant' || $tenantSlug === '' || !$this->database->isConfigured()) {
+ return null;
+ }
+
+ $tenant = (new TenantContext($this->database->pdo()))->bySlug($tenantSlug);
+
+ return is_array($tenant) ? $tenant : null;
+ }
+
+ private function passwordResetContext(string $scope, ?array $tenant, string $tenantSlug): array
+ {
+ $context = ['scope' => $scope];
+ $resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
+
+ if ($scope === 'tenant' && is_string($resolvedSlug) && $resolvedSlug !== '') {
+ $context['tenant'] = $resolvedSlug;
+ }
+
+ return $context;
+ }
+
+ private function passwordResetRequestUrl(string $scope, ?array $tenant, string $tenantSlug): string
+ {
+ $query = http_build_query($this->passwordResetContext($scope, $tenant, $tenantSlug), '', '&', PHP_QUERY_RFC3986);
+
+ return '/password/forgot' . ($query !== '' ? '?' . $query : '');
+ }
+
+ private function passwordResetConfirmUrl(string $token, string $scope, ?array $tenant, string $tenantSlug): string
+ {
+ $query = $this->passwordResetContext($scope, $tenant, $tenantSlug);
+
+ if (trim($token) !== '') {
+ $query['token'] = $token;
+ }
+
+ $encoded = http_build_query($query, '', '&', PHP_QUERY_RFC3986);
+
+ return '/reset-password' . ($encoded !== '' ? '?' . $encoded : '');
+ }
+
+ private function passwordResetLoginUrl(string $scope, ?array $tenant, string $tenantSlug): string
+ {
+ if ($scope === 'tenant') {
+ $resolvedSlug = $tenant['slug'] ?? trim($tenantSlug);
+
+ if (is_string($resolvedSlug) && $resolvedSlug !== '') {
+ return tenant_url($resolvedSlug, 'login');
+ }
+ }
+
+ return '/admin/login';
+ }
}
diff --git a/app/Controllers/TenantController.php b/app/Controllers/TenantController.php
index c9dfd14..c7d78b0 100644
--- a/app/Controllers/TenantController.php
+++ b/app/Controllers/TenantController.php
@@ -10,7 +10,9 @@ use App\Core\Session;
use App\Core\TenantContext;
use App\Core\View;
use App\Services\AuditService;
+use App\Services\ExportService;
use App\Services\LedgerService;
+use App\Services\RateLimiterService;
use App\Services\RfidService;
use RuntimeException;
@@ -20,7 +22,8 @@ final class TenantController
private readonly Database $database,
private readonly View $view,
private readonly Session $session,
- private readonly Csrf $csrf
+ private readonly Csrf $csrf,
+ private readonly array $config = []
) {
}
@@ -37,24 +40,43 @@ final class TenantController
public function loginSubmit(string $tenantSlug): void
{
$this->assertCsrf();
- $tenant = $this->tenant($tenantSlug);
- $pdo = $this->database->pdo();
- $auth = new Auth($pdo, $this->session);
- $audit = new AuditService($pdo);
- $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
- $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
- 'email' => mb_strtolower(trim((string) ($_POST['email'] ?? ''))),
- ]);
+ try {
+ $tenant = $this->tenant($tenantSlug);
+ $pdo = $this->database->pdo();
+ $auth = new Auth($pdo, $this->session);
+ $audit = new AuditService($pdo);
+ $email = mb_strtolower(trim((string) ($_POST['email'] ?? '')));
+ $rateLimiter = $this->rateLimiter($pdo);
+ $scopes = $this->buildRateLimitScopes($tenantSlug, $email);
- if (!$success) {
+ $this->ensureRateLimitAllowed($rateLimiter, $scopes);
+
+ $success = $auth->attemptTenantLogin($tenantSlug, (string) ($_POST['email'] ?? ''), (string) ($_POST['password'] ?? ''));
+ $audit->log((int) $tenant['id'], null, 'tenant.login', 'user', null, $success ? 'success' : 'failure', [
+ 'email' => $email,
+ ]);
+
+ if (!$success) {
+ $this->hitRateLimitScopes($rateLimiter, $scopes);
+ throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
+ }
+
+ $authState = $auth->user() ?? [];
+
+ if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
+ $auth->logout();
+ throw new RuntimeException('Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.');
+ }
+
+ $this->clearRateLimitScopes($rateLimiter, $scopes);
+ $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
+ Response::redirect(tenant_url($tenantSlug));
+ } catch (\Throwable $throwable) {
remember_old_input($_POST);
- $this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.');
+ $this->session->flash('error', $throwable->getMessage());
Response::redirect(tenant_url($tenantSlug, 'login'));
}
-
- $this->session->flash('success', 'Willkommen zur digitalen Kaffeeliste.');
- Response::redirect(tenant_url($tenantSlug));
}
public function logout(string $tenantSlug): void
@@ -71,54 +93,144 @@ final class TenantController
{
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
$pdo = $this->database->pdo();
+ $isManager = $this->isTenantManager($authState);
+ $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
- $summaryStatement = $pdo->prepare(
- 'SELECT
- COUNT(DISTINCT members.id) AS member_count,
- COUNT(DISTINCT products.id) AS product_count,
- COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
- COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
- FROM tenants
- LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
- LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
- LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
- LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
- WHERE tenants.id = :tenant_id
- GROUP BY tenants.id'
- );
- $summaryStatement->execute(['tenant_id' => $tenant['id']]);
- $summary = $summaryStatement->fetch() ?: [
- 'member_count' => 0,
- 'product_count' => 0,
- 'paper_sheet_count' => 0,
- 'open_balance_cents' => 0,
- ];
+ if ($isManager) {
+ $summaryStatement = $pdo->prepare(
+ 'SELECT
+ COUNT(DISTINCT members.id) AS member_count,
+ COUNT(DISTINCT products.id) AS product_count,
+ COUNT(DISTINCT paper_sheets.id) AS paper_sheet_count,
+ COALESCE(SUM(CASE WHEN ledger_lines.account_code = "member_balance" THEN ledger_lines.amount_cents ELSE 0 END), 0) AS open_balance_cents
+ FROM tenants
+ LEFT JOIN members ON members.tenant_id = tenants.id AND members.status = "active"
+ LEFT JOIN products ON products.tenant_id = tenants.id AND products.is_active = 1
+ LEFT JOIN paper_sheets ON paper_sheets.tenant_id = tenants.id
+ LEFT JOIN ledger_lines ON ledger_lines.tenant_id = tenants.id
+ WHERE tenants.id = :tenant_id
+ GROUP BY tenants.id'
+ );
+ $summaryStatement->execute(['tenant_id' => $tenant['id']]);
+ $summary = $summaryStatement->fetch() ?: [
+ 'member_count' => 0,
+ 'product_count' => 0,
+ 'paper_sheet_count' => 0,
+ 'open_balance_cents' => 0,
+ ];
- $eventsStatement = $pdo->prepare(
- 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
- FROM consumption_events c
- INNER JOIN members m ON m.id = c.member_id
- INNER JOIN products p ON p.id = c.product_id
- INNER JOIN capture_sources s ON s.id = c.source_id
- WHERE c.tenant_id = :tenant_id
- ORDER BY c.recorded_at DESC
- LIMIT 8'
- );
- $eventsStatement->execute(['tenant_id' => $tenant['id']]);
+ $eventsStatement = $pdo->prepare(
+ 'SELECT c.*,
+ m.display_name AS member_name,
+ p.name AS product_name,
+ s.name AS source_name,
+ CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
+ FROM consumption_events c
+ INNER JOIN members m ON m.id = c.member_id
+ INNER JOIN products p ON p.id = c.product_id
+ INNER JOIN capture_sources s ON s.id = c.source_id
+ LEFT JOIN ledger_entries le
+ ON le.tenant_id = c.tenant_id
+ AND le.reference_type = "consumption_event"
+ AND le.reference_id = c.id
+ AND le.type = "consumption"
+ LEFT JOIN ledger_entries reversal
+ ON reversal.tenant_id = le.tenant_id
+ AND reversal.reversal_of_entry_id = le.id
+ WHERE c.tenant_id = :tenant_id
+ ORDER BY c.recorded_at DESC
+ LIMIT 8'
+ );
+ $eventsStatement->execute(['tenant_id' => $tenant['id']]);
- $balancesStatement = $pdo->prepare(
- 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
- FROM members m
- LEFT JOIN ledger_lines ll
- ON ll.member_id = m.id
- AND ll.tenant_id = m.tenant_id
- AND ll.account_code = "member_balance"
- WHERE m.tenant_id = :tenant_id
- GROUP BY m.id
- ORDER BY balance_cents DESC, m.display_name ASC
- LIMIT 8'
- );
- $balancesStatement->execute(['tenant_id' => $tenant['id']]);
+ $balancesStatement = $pdo->prepare(
+ 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
+ FROM members m
+ LEFT JOIN ledger_lines ll
+ ON ll.member_id = m.id
+ AND ll.tenant_id = m.tenant_id
+ AND ll.account_code = "member_balance"
+ WHERE m.tenant_id = :tenant_id
+ GROUP BY m.id
+ ORDER BY balance_cents DESC, m.display_name ASC
+ LIMIT 8'
+ );
+ $balancesStatement->execute(['tenant_id' => $tenant['id']]);
+ } else {
+ if ($currentMember === null) {
+ throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
+ }
+
+ $summaryStatement = $pdo->prepare(
+ 'SELECT
+ 1 AS member_count,
+ (SELECT COUNT(*) FROM products WHERE tenant_id = :tenant_id_products AND is_active = 1) AS product_count,
+ 0 AS paper_sheet_count,
+ COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0) AS open_balance_cents
+ FROM members m
+ LEFT JOIN ledger_lines ll
+ ON ll.member_id = m.id
+ AND ll.tenant_id = m.tenant_id
+ WHERE m.tenant_id = :tenant_id
+ AND m.id = :member_id
+ GROUP BY m.id'
+ );
+ $summaryStatement->execute([
+ 'tenant_id_products' => $tenant['id'],
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $currentMember['id'],
+ ]);
+ $summary = $summaryStatement->fetch() ?: [
+ 'member_count' => 1,
+ 'product_count' => 0,
+ 'paper_sheet_count' => 0,
+ 'open_balance_cents' => 0,
+ ];
+
+ $eventsStatement = $pdo->prepare(
+ 'SELECT c.*,
+ m.display_name AS member_name,
+ p.name AS product_name,
+ s.name AS source_name,
+ CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
+ FROM consumption_events c
+ INNER JOIN members m ON m.id = c.member_id
+ INNER JOIN products p ON p.id = c.product_id
+ INNER JOIN capture_sources s ON s.id = c.source_id
+ LEFT JOIN ledger_entries le
+ ON le.tenant_id = c.tenant_id
+ AND le.reference_type = "consumption_event"
+ AND le.reference_id = c.id
+ AND le.type = "consumption"
+ LEFT JOIN ledger_entries reversal
+ ON reversal.tenant_id = le.tenant_id
+ AND reversal.reversal_of_entry_id = le.id
+ WHERE c.tenant_id = :tenant_id
+ AND c.member_id = :member_id
+ ORDER BY c.recorded_at DESC
+ LIMIT 8'
+ );
+ $eventsStatement->execute([
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $currentMember['id'],
+ ]);
+
+ $balancesStatement = $pdo->prepare(
+ 'SELECT m.id, m.display_name, COALESCE(SUM(ll.amount_cents), 0) AS balance_cents
+ FROM members m
+ LEFT JOIN ledger_lines ll
+ ON ll.member_id = m.id
+ AND ll.tenant_id = m.tenant_id
+ AND ll.account_code = "member_balance"
+ WHERE m.tenant_id = :tenant_id
+ AND m.id = :member_id
+ GROUP BY m.id'
+ );
+ $balancesStatement->execute([
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $currentMember['id'],
+ ]);
+ }
$this->view->render('tenant/dashboard', [
'title' => 'Dashboard',
@@ -127,6 +239,8 @@ final class TenantController
'summary' => $summary,
'latestEvents' => $eventsStatement->fetchAll(),
'memberBalances' => $balancesStatement->fetchAll(),
+ 'isManager' => $isManager,
+ 'currentMember' => $currentMember,
]);
}
@@ -148,6 +262,10 @@ final class TenantController
throw new RuntimeException('Bitte einen Anzeigenamen angeben.');
}
+ if ($accessPin !== '' && mb_strlen($accessPin) < 4) {
+ throw new RuntimeException('Die PIN muss mindestens 4 Zeichen haben.');
+ }
+
$userId = null;
if ($email !== '') {
@@ -159,6 +277,13 @@ final class TenantController
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
}
+ $existingUserStatement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
+ $existingUserStatement->execute(['email' => mb_strtolower($email)]);
+
+ if ($existingUserStatement->fetchColumn()) {
+ throw new RuntimeException('Die E-Mail-Adresse ist bereits im System vorhanden.');
+ }
+
$userInsert = $pdo->prepare(
'INSERT INTO users (full_name, email, password_hash, platform_role)
VALUES (:full_name, :email, :password_hash, "user")'
@@ -189,7 +314,7 @@ final class TenantController
'user_id' => $userId,
'display_name' => $displayName,
'email' => $email !== '' ? mb_strtolower($email) : null,
- 'access_pin' => $accessPin !== '' ? $accessPin : null,
+ 'access_pin' => $accessPin !== '' ? secure_password_hash($accessPin) : null,
]);
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'member.created', 'member', (int) $pdo->lastInsertId(), 'success');
@@ -291,23 +416,44 @@ final class TenantController
{
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
$pdo = $this->database->pdo();
+ $isManager = $this->isTenantManager($authState);
+ $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$this->assertCsrf();
try {
$service = new LedgerService($pdo, new AuditService($pdo));
- $service->createConsumption(
- (int) $tenant['id'],
- (int) ($_POST['member_id'] ?? 0),
- (int) ($_POST['product_id'] ?? 0),
- (string) ($_POST['source_code'] ?? 'digital_self'),
- normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
- (int) $authState['user_id'],
- (float) ($_POST['quantity'] ?? 1),
- trim((string) ($_POST['notes'] ?? ''))
- );
- $this->session->flash('success', 'Die Buchung wurde gespeichert.');
+ $action = (string) ($_POST['action'] ?? 'create_booking');
+
+ if ($action === 'reverse_booking') {
+ if (!$isManager) {
+ throw new RuntimeException('Nur Admins duerfen Buchungen stornieren.');
+ }
+
+ $service->reverseLedgerEntry(
+ (int) $tenant['id'],
+ (int) ($_POST['ledger_entry_id'] ?? 0),
+ (int) $authState['user_id'],
+ trim((string) ($_POST['reason'] ?? '')) ?: null
+ );
+ $this->session->flash('success', 'Die Buchung wurde storniert.');
+ } else {
+ $memberId = $this->selectedMemberIdForBooking((int) $tenant['id'], $authState, (int) ($_POST['member_id'] ?? 0), $currentMember);
+ $sourceCode = $isManager ? (string) ($_POST['source_code'] ?? 'digital_self') : 'digital_self';
+
+ $service->createConsumption(
+ (int) $tenant['id'],
+ $memberId,
+ (int) ($_POST['product_id'] ?? 0),
+ $sourceCode,
+ normalize_datetime_input((string) ($_POST['effective_at'] ?? '')),
+ (int) $authState['user_id'],
+ (float) ($_POST['quantity'] ?? 1),
+ trim((string) ($_POST['notes'] ?? ''))
+ );
+ $this->session->flash('success', 'Die Buchung wurde gespeichert.');
+ }
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
@@ -316,20 +462,46 @@ final class TenantController
Response::redirect(tenant_url($tenantSlug, 'bookings'));
}
- $members = $this->membersForTenant((int) $tenant['id']);
+ $members = $isManager
+ ? $this->membersForTenant((int) $tenant['id'])
+ : ($currentMember !== null ? [['id' => $currentMember['id'], 'display_name' => $currentMember['display_name']]] : []);
$products = $this->productsForTenant((int) $tenant['id']);
- $bookingsStatement = $pdo->prepare(
- 'SELECT c.*, m.display_name AS member_name, p.name AS product_name, s.name AS source_name
- FROM consumption_events c
- INNER JOIN members m ON m.id = c.member_id
- INNER JOIN products p ON p.id = c.product_id
- INNER JOIN capture_sources s ON s.id = c.source_id
- WHERE c.tenant_id = :tenant_id
- ORDER BY c.recorded_at DESC
- LIMIT 30'
- );
- $bookingsStatement->execute(['tenant_id' => $tenant['id']]);
+ $sql = 'SELECT c.*,
+ m.display_name AS member_name,
+ p.name AS product_name,
+ s.name AS source_name,
+ le.id AS ledger_entry_id,
+ CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
+ FROM consumption_events c
+ INNER JOIN members m ON m.id = c.member_id
+ INNER JOIN products p ON p.id = c.product_id
+ INNER JOIN capture_sources s ON s.id = c.source_id
+ LEFT JOIN ledger_entries le
+ ON le.tenant_id = c.tenant_id
+ AND le.reference_type = "consumption_event"
+ AND le.reference_id = c.id
+ AND le.type = "consumption"
+ LEFT JOIN ledger_entries reversal
+ ON reversal.tenant_id = le.tenant_id
+ AND reversal.reversal_of_entry_id = le.id
+ WHERE c.tenant_id = :tenant_id';
+
+ $params = ['tenant_id' => $tenant['id']];
+
+ if (!$isManager) {
+ if ($currentMember === null) {
+ throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
+ }
+
+ $sql .= ' AND c.member_id = :member_id';
+ $params['member_id'] = $currentMember['id'];
+ }
+
+ $sql .= ' ORDER BY c.recorded_at DESC LIMIT 30';
+
+ $bookingsStatement = $pdo->prepare($sql);
+ $bookingsStatement->execute($params);
$this->view->render('tenant/bookings', [
'title' => 'Digitale Buchungen',
@@ -338,9 +510,80 @@ final class TenantController
'members' => $members,
'products' => $products,
'bookings' => $bookingsStatement->fetchAll(),
+ 'isManager' => $isManager,
+ 'currentMember' => $currentMember,
]);
}
+ public function exportBalances(string $tenantSlug): void
+ {
+ [$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
+
+ (new AuditService($this->database->pdo()))->log(
+ (int) $tenant['id'],
+ (int) $authState['user_id'],
+ 'export.member_balances',
+ 'tenant',
+ (int) $tenant['id'],
+ 'success'
+ );
+
+ (new ExportService($this->database->pdo()))->streamMemberBalancesCsv(
+ (int) $tenant['id'],
+ $tenant['slug'] . '-salden-' . gmdate('Ymd-His') . '.csv'
+ );
+ }
+
+ public function exportBookings(string $tenantSlug): void
+ {
+ [$tenant, $authState] = $this->tenantAccess($tenantSlug);
+ $isManager = $this->isTenantManager($authState);
+ $currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
+ $memberId = null;
+
+ if ($isManager) {
+ $requestedMemberId = (int) ($_GET['member_id'] ?? 0);
+
+ if ($requestedMemberId > 0) {
+ $memberId = $this->assertMemberIdBelongsToTenant((int) $tenant['id'], $requestedMemberId);
+ }
+ } else {
+ if ($currentMember === null) {
+ throw new RuntimeException('Fuer diesen Benutzer ist kein aktives Mitglied im Mandanten hinterlegt.');
+ }
+
+ $memberId = (int) $currentMember['id'];
+ }
+
+ (new AuditService($this->database->pdo()))->log(
+ (int) $tenant['id'],
+ (int) $authState['user_id'],
+ 'export.bookings',
+ 'tenant',
+ (int) $tenant['id'],
+ 'success',
+ [
+ 'member_id' => $memberId,
+ 'from' => trim((string) ($_GET['from'] ?? '')) ?: null,
+ 'until' => trim((string) ($_GET['until'] ?? '')) ?: null,
+ ]
+ );
+
+ $filename = $tenant['slug'] . '-buchungen-' . gmdate('Ymd-His') . '.csv';
+
+ if ($memberId !== null) {
+ $filename = $tenant['slug'] . '-mitglied-' . $memberId . '-buchungen-' . gmdate('Ymd-His') . '.csv';
+ }
+
+ (new ExportService($this->database->pdo()))->streamBookingsCsv(
+ (int) $tenant['id'],
+ $memberId,
+ trim((string) ($_GET['from'] ?? '')) ?: null,
+ trim((string) ($_GET['until'] ?? '')) ?: null,
+ $filename
+ );
+ }
+
public function payments(string $tenantSlug): void
{
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
@@ -351,15 +594,27 @@ final class TenantController
try {
$service = new LedgerService($pdo, new AuditService($pdo));
- $service->createPayment(
- (int) $tenant['id'],
- (int) ($_POST['member_id'] ?? 0),
- (int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
- normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
- (int) $authState['user_id'],
- trim((string) ($_POST['notes'] ?? ''))
- );
- $this->session->flash('success', 'Die Einzahlung wurde gebucht.');
+ $action = (string) ($_POST['action'] ?? 'create_payment');
+
+ if ($action === 'reverse_payment') {
+ $service->reverseLedgerEntry(
+ (int) $tenant['id'],
+ (int) ($_POST['ledger_entry_id'] ?? 0),
+ (int) $authState['user_id'],
+ trim((string) ($_POST['reason'] ?? '')) ?: null
+ );
+ $this->session->flash('success', 'Die Einzahlung wurde storniert.');
+ } else {
+ $service->createPayment(
+ (int) $tenant['id'],
+ (int) ($_POST['member_id'] ?? 0),
+ (int) round(((float) ($_POST['amount_eur'] ?? 0)) * 100),
+ normalize_datetime_input((string) ($_POST['occurred_at'] ?? '')),
+ (int) $authState['user_id'],
+ trim((string) ($_POST['notes'] ?? ''))
+ );
+ $this->session->flash('success', 'Die Einzahlung wurde gebucht.');
+ }
} catch (\Throwable $throwable) {
remember_old_input($_POST);
$this->session->flash('error', $throwable->getMessage());
@@ -372,10 +627,14 @@ final class TenantController
$paymentsStatement = $pdo->prepare(
'SELECT le.*,
m.display_name AS member_name,
- ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents
+ ABS(COALESCE(SUM(CASE WHEN ll.account_code = "member_balance" THEN ll.amount_cents ELSE 0 END), 0)) AS amount_cents,
+ CASE WHEN reversal.id IS NULL THEN 0 ELSE 1 END AS is_reversed
FROM ledger_entries le
LEFT JOIN members m ON m.id = le.member_id
LEFT JOIN ledger_lines ll ON ll.ledger_entry_id = le.id
+ LEFT JOIN ledger_entries reversal
+ ON reversal.tenant_id = le.tenant_id
+ AND reversal.reversal_of_entry_id = le.id
WHERE le.tenant_id = :tenant_id
AND le.type = "payment"
GROUP BY le.id
@@ -390,6 +649,7 @@ final class TenantController
'authState' => $authState,
'members' => $members,
'payments' => $paymentsStatement->fetchAll(),
+ 'isManager' => true,
]);
}
@@ -397,7 +657,6 @@ final class TenantController
{
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
$pdo = $this->database->pdo();
-
$service = new LedgerService($pdo, new AuditService($pdo));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -432,6 +691,14 @@ final class TenantController
} elseif ($action === 'post_sheet') {
$service->postPaperSheet((int) $tenant['id'], (int) ($_POST['paper_sheet_id'] ?? 0), (int) $authState['user_id']);
$this->session->flash('success', 'Die Papierliste wurde ins Ledger uebernommen.');
+ } elseif ($action === 'reverse_sheet') {
+ $service->reversePaperSheet(
+ (int) $tenant['id'],
+ (int) ($_POST['paper_sheet_id'] ?? 0),
+ (int) $authState['user_id'],
+ trim((string) ($_POST['reason'] ?? '')) ?: null
+ );
+ $this->session->flash('success', 'Die verbuchte Papierliste wurde storniert.');
}
} catch (\Throwable $throwable) {
remember_old_input($_POST);
@@ -444,9 +711,21 @@ final class TenantController
$members = $this->membersForTenant((int) $tenant['id']);
$products = $this->productsForTenant((int) $tenant['id']);
$sheetsStatement = $pdo->prepare(
- 'SELECT ps.*, COUNT(psl.id) AS line_count
+ 'SELECT ps.*,
+ COUNT(psl.id) AS line_count,
+ COUNT(DISTINCT le.id) AS posted_entry_count,
+ COUNT(DISTINCT reversal.id) AS reversed_entry_count
FROM paper_sheets ps
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.id
+ LEFT JOIN consumption_events ce ON ce.paper_sheet_line_id = psl.id
+ LEFT JOIN ledger_entries le
+ ON le.reference_type = "consumption_event"
+ AND le.reference_id = ce.id
+ AND le.tenant_id = ps.tenant_id
+ AND le.type = "consumption"
+ LEFT JOIN ledger_entries reversal
+ ON reversal.reversal_of_entry_id = le.id
+ AND reversal.tenant_id = le.tenant_id
WHERE ps.tenant_id = :tenant_id
GROUP BY ps.id
ORDER BY ps.created_at DESC'
@@ -472,6 +751,7 @@ final class TenantController
'products' => $products,
'sheets' => $sheets,
'sheetLines' => $linesStatement->fetchAll(),
+ 'isManager' => true,
]);
}
@@ -542,11 +822,18 @@ final class TenantController
'devices' => $devices->fetchAll(),
'tags' => $tags->fetchAll(),
'events' => $events->fetchAll(),
+ 'isManager' => true,
+ 'rfidIngestEnabled' => (bool) ($this->config['rfid_ingest_enabled'] ?? false),
]);
}
public function rfidIngest(): void
{
+ if (!($this->config['rfid_ingest_enabled'] ?? false)) {
+ Response::notFound();
+ return;
+ }
+
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
if (!is_array($payload)) {
@@ -596,7 +883,20 @@ final class TenantController
Response::redirect(tenant_url($tenantSlug));
}
- return [$tenant, $auth->user()];
+ $authState = $auth->user();
+
+ if (!is_array($authState)) {
+ $this->session->flash('error', 'Bitte zuerst anmelden.');
+ Response::redirect(tenant_url($tenantSlug, 'login'));
+ }
+
+ if (!$this->isTenantManager($authState) && !$this->selfServiceAllowed((int) $tenant['id'])) {
+ $auth->logout();
+ $this->session->flash('error', 'Die digitale Selbstbuchung ist fuer diesen Mandanten derzeit deaktiviert.');
+ Response::redirect(tenant_url($tenantSlug, 'login'));
+ }
+
+ return [$tenant, $authState];
}
private function membersForTenant(int $tenantId): array
@@ -627,10 +927,147 @@ final class TenantController
return $statement->fetchAll();
}
+ private function memberRecordForAuthState(int $tenantId, array $authState): ?array
+ {
+ $memberId = $authState['member_id'] ?? null;
+
+ if (!is_int($memberId)) {
+ return null;
+ }
+
+ $statement = $this->database->pdo()->prepare(
+ 'SELECT id, display_name, email
+ FROM members
+ WHERE tenant_id = :tenant_id
+ AND id = :id
+ AND status = "active"
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'tenant_id' => $tenantId,
+ 'id' => $memberId,
+ ]);
+ $member = $statement->fetch();
+
+ return $member ?: null;
+ }
+
+ private function isTenantManager(array $authState): bool
+ {
+ $role = $authState['tenant_role'] ?? null;
+
+ return is_string($role) && in_array($role, ['owner', 'admin'], true);
+ }
+
+ private function selectedMemberIdForBooking(
+ int $tenantId,
+ array $authState,
+ int $postedMemberId,
+ ?array $currentMember
+ ): int {
+ if ($this->isTenantManager($authState)) {
+ if ($postedMemberId <= 0) {
+ throw new RuntimeException('Bitte ein Mitglied fuer die Buchung auswaehlen.');
+ }
+
+ return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId);
+ }
+
+ if ($currentMember === null) {
+ throw new RuntimeException('Fuer dieses Konto ist kein buchungsfaehiges Mitglied hinterlegt.');
+ }
+
+ if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) {
+ throw new RuntimeException('Du darfst nur fuer dein eigenes Mitglied buchen.');
+ }
+
+ return (int) $currentMember['id'];
+ }
+
+ private function assertMemberIdBelongsToTenant(int $tenantId, int $memberId): int
+ {
+ $statement = $this->database->pdo()->prepare(
+ 'SELECT id
+ FROM members
+ WHERE tenant_id = :tenant_id
+ AND id = :id
+ AND status = "active"
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'tenant_id' => $tenantId,
+ 'id' => $memberId,
+ ]);
+
+ if (!$statement->fetchColumn()) {
+ throw new RuntimeException('Das angegebene Mitglied gehoert nicht zu diesem Mandanten.');
+ }
+
+ return $memberId;
+ }
+
private function assertCsrf(): void
{
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
}
}
+
+ private function selfServiceAllowed(int $tenantId): bool
+ {
+ $statement = $this->database->pdo()->prepare(
+ 'SELECT allow_self_service
+ FROM tenant_settings
+ WHERE tenant_id = :tenant_id
+ LIMIT 1'
+ );
+ $statement->execute(['tenant_id' => $tenantId]);
+ $value = $statement->fetchColumn();
+
+ return $value === false ? true : (int) $value === 1;
+ }
+
+ private function rateLimiter(\PDO $pdo): RateLimiterService
+ {
+ return new RateLimiterService($pdo, $this->config['rate_limits'] ?? []);
+ }
+
+ private function buildRateLimitScopes(string $tenantSlug, string $email): array
+ {
+ $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'));
+ $scopes = ['tenant-login|ip|' . $tenantSlug . '|' . $ip];
+
+ if (trim($email) !== '') {
+ $scopes[] = 'tenant-login|subject|' . $tenantSlug . '|' . $email;
+ }
+
+ return $scopes;
+ }
+
+ private function ensureRateLimitAllowed(RateLimiterService $rateLimiter, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $state = $rateLimiter->inspect('login', $scope);
+
+ if (!$state['allowed']) {
+ throw new RuntimeException(
+ 'Zu viele Login-Versuche. Bitte in etwa ' . (int) $state['retry_after_seconds'] . ' Sekunden erneut versuchen.'
+ );
+ }
+ }
+ }
+
+ private function hitRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $rateLimiter->hit('login', $scope);
+ }
+ }
+
+ private function clearRateLimitScopes(RateLimiterService $rateLimiter, array $scopes): void
+ {
+ foreach ($scopes as $scope) {
+ $rateLimiter->clear('login', $scope);
+ }
+ }
}
diff --git a/app/Core/Auth.php b/app/Core/Auth.php
index d1442cf..bd11f89 100644
--- a/app/Core/Auth.php
+++ b/app/Core/Auth.php
@@ -28,6 +28,8 @@ final class Auth
return false;
}
+ $this->rehashPasswordIfNeeded((int) $user['id'], (string) $user['password_hash'], $password);
+
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $user['id'],
@@ -44,10 +46,15 @@ final class Auth
u.password_hash,
m.tenant_id,
m.role,
- t.slug AS tenant_slug
+ t.slug AS tenant_slug,
+ member.id AS member_id
FROM users u
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
+ LEFT JOIN members member
+ ON member.user_id = u.id
+ AND member.tenant_id = m.tenant_id
+ AND member.status = "active"
WHERE u.email = :email
LIMIT 1'
);
@@ -61,12 +68,15 @@ final class Auth
return false;
}
+ $this->rehashPasswordIfNeeded((int) $membership['id'], (string) $membership['password_hash'], $password);
+
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $membership['id'],
'tenant_id' => (int) $membership['tenant_id'],
'tenant_slug' => $membership['tenant_slug'],
'tenant_role' => $membership['role'],
+ 'member_id' => isset($membership['member_id']) ? (int) $membership['member_id'] : null,
'platform_role' => 'user',
]);
@@ -101,8 +111,37 @@ final class Auth
return is_string($role) && in_array($role, $allowedRoles, true);
}
+ public function tenantMemberId(): ?int
+ {
+ $memberId = $this->user()['member_id'] ?? null;
+
+ return is_int($memberId) ? $memberId : null;
+ }
+
+ public function canManageTenant(): bool
+ {
+ $role = $this->user()['tenant_role'] ?? null;
+
+ return is_string($role) && in_array($role, ['owner', 'admin'], true);
+ }
+
public function logout(): void
{
$this->session->invalidate();
}
+
+ private function rehashPasswordIfNeeded(int $userId, string $currentHash, string $password): void
+ {
+ if (!password_needs_rehash($currentHash, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT)) {
+ return;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'UPDATE users SET password_hash = :password_hash WHERE id = :id'
+ );
+ $statement->execute([
+ 'password_hash' => secure_password_hash($password),
+ 'id' => $userId,
+ ]);
+ }
}
diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php
new file mode 100644
index 0000000..9fef981
--- /dev/null
+++ b/app/Services/ExportService.php
@@ -0,0 +1,408 @@
+assertTenantId($tenantId);
+
+ $statement = $this->pdo->prepare(
+ 'SELECT
+ m.id AS member_id,
+ m.display_name,
+ m.email,
+ m.status,
+ m.created_at,
+ COALESCE(balance_summary.balance_cents, 0) AS balance_cents,
+ balance_summary.last_occurred_at
+ FROM members m
+ LEFT JOIN (
+ SELECT
+ ll.member_id,
+ SUM(ll.amount_cents) AS balance_cents,
+ MAX(le.occurred_at) AS last_occurred_at
+ FROM ledger_lines ll
+ INNER JOIN ledger_entries le
+ ON le.id = ll.ledger_entry_id
+ AND le.tenant_id = ll.tenant_id
+ WHERE ll.tenant_id = :balance_tenant_id
+ AND ll.account_code = "member_balance"
+ AND ll.member_id IS NOT NULL
+ GROUP BY ll.member_id
+ ) balance_summary
+ ON balance_summary.member_id = m.id
+ WHERE m.tenant_id = :tenant_id
+ ORDER BY m.display_name ASC, m.id ASC'
+ );
+ $statement->execute([
+ 'balance_tenant_id' => $tenantId,
+ 'tenant_id' => $tenantId,
+ ]);
+
+ $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('member-balances', $tenantId));
+
+ try {
+ $this->writeCsvRow($handle, [
+ 'mitglied_id',
+ 'anzeigename',
+ 'email',
+ 'status',
+ 'saldo_cents',
+ 'saldo_eur',
+ 'letzte_buchung_am',
+ 'angelegt_am',
+ ]);
+
+ $rowCount = 0;
+
+ while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
+ $balanceCents = (int) $row['balance_cents'];
+
+ $this->writeCsvRow($handle, [
+ (int) $row['member_id'],
+ (string) $row['display_name'],
+ $row['email'],
+ (string) $row['status'],
+ $balanceCents,
+ $this->formatMoneyFromCents($balanceCents),
+ $row['last_occurred_at'],
+ (string) $row['created_at'],
+ ]);
+
+ $rowCount++;
+ $this->flushIfNeeded($rowCount);
+ }
+ } finally {
+ fclose($handle);
+ }
+ }
+
+ public function streamBookingsCsv(
+ int $tenantId,
+ ?int $memberId = null,
+ ?string $from = null,
+ ?string $until = null,
+ ?string $filename = null
+ ): void {
+ $this->assertTenantId($tenantId);
+
+ if ($memberId !== null && $memberId <= 0) {
+ throw new RuntimeException('Die Mitglieds-ID muss groesser als 0 sein.');
+ }
+
+ $from = $this->normalizeDateTimeFilter($from, false);
+ $until = $this->normalizeDateTimeFilter($until, true);
+
+ $sql = 'SELECT
+ le.id AS booking_id,
+ le.type,
+ le.reference_type,
+ le.reference_id,
+ le.description,
+ le.occurred_at,
+ le.created_at AS entry_created_at,
+ le.member_id,
+ m.display_name AS member_name,
+ s.code AS source_code,
+ s.name AS source_name,
+ COALESCE(line_totals.member_balance_cents, 0) AS member_balance_cents,
+ COALESCE(line_totals.cashbox_cents, 0) AS cashbox_cents,
+ COALESCE(line_totals.coffee_revenue_cents, 0) AS coffee_revenue_cents,
+ c.id AS consumption_event_id,
+ c.quantity,
+ c.unit_price_cents,
+ c.total_cents,
+ c.effective_at,
+ c.recorded_at,
+ c.source_reference,
+ c.notes,
+ c.paper_sheet_line_id,
+ p.id AS product_id,
+ p.name AS product_name
+ FROM ledger_entries le
+ LEFT JOIN members m
+ ON m.id = le.member_id
+ AND m.tenant_id = le.tenant_id
+ LEFT JOIN capture_sources s
+ ON s.id = le.source_id
+ AND s.tenant_id = le.tenant_id
+ LEFT JOIN (
+ SELECT
+ ledger_entry_id,
+ SUM(CASE WHEN account_code = "member_balance" THEN amount_cents ELSE 0 END) AS member_balance_cents,
+ SUM(CASE WHEN account_code = "cashbox" THEN amount_cents ELSE 0 END) AS cashbox_cents,
+ SUM(CASE WHEN account_code = "coffee_revenue" THEN amount_cents ELSE 0 END) AS coffee_revenue_cents
+ FROM ledger_lines
+ WHERE tenant_id = :line_tenant_id
+ GROUP BY ledger_entry_id
+ ) line_totals
+ ON line_totals.ledger_entry_id = le.id
+ LEFT JOIN consumption_events c
+ ON c.id = le.reference_id
+ AND c.tenant_id = le.tenant_id
+ AND le.reference_type = "consumption_event"
+ LEFT JOIN products p
+ ON p.id = c.product_id
+ AND p.tenant_id = c.tenant_id
+ WHERE le.tenant_id = :tenant_id';
+
+ $params = [
+ 'line_tenant_id' => $tenantId,
+ 'tenant_id' => $tenantId,
+ ];
+
+ if ($memberId !== null) {
+ $sql .= ' AND le.member_id = :member_id';
+ $params['member_id'] = $memberId;
+ }
+
+ if ($from !== null) {
+ $sql .= ' AND le.occurred_at >= :from_occurred_at';
+ $params['from_occurred_at'] = $from;
+ }
+
+ if ($until !== null) {
+ $sql .= ' AND le.occurred_at <= :until_occurred_at';
+ $params['until_occurred_at'] = $until;
+ }
+
+ $sql .= ' ORDER BY le.occurred_at DESC, le.id DESC';
+
+ $statement = $this->pdo->prepare($sql);
+ $statement->execute($params);
+
+ $handle = $this->openCsvOutput($filename ?? $this->defaultFilename('bookings', $tenantId));
+
+ try {
+ $this->writeCsvRow($handle, [
+ 'buchung_id',
+ 'typ',
+ 'buchungsdatum',
+ 'mitglied_id',
+ 'mitglied_name',
+ 'beschreibung',
+ 'quellcode',
+ 'quelle',
+ 'referenz_typ',
+ 'referenz_id',
+ 'saldo_delta_cents',
+ 'saldo_delta_eur',
+ 'kasse_delta_cents',
+ 'kasse_delta_eur',
+ 'umsatz_delta_cents',
+ 'umsatz_delta_eur',
+ 'produkt_id',
+ 'produkt_name',
+ 'menge',
+ 'einzelpreis_cents',
+ 'einzelpreis_eur',
+ 'gesamt_cents',
+ 'gesamt_eur',
+ 'notizen',
+ 'wirksam_am',
+ 'erfasst_am',
+ 'quellreferenz',
+ 'papierlisten_zeile_id',
+ 'angelegt_am',
+ ]);
+
+ $rowCount = 0;
+
+ while (($row = $statement->fetch(PDO::FETCH_ASSOC)) !== false) {
+ $memberBalanceCents = (int) $row['member_balance_cents'];
+ $cashboxCents = (int) $row['cashbox_cents'];
+ $coffeeRevenueCents = (int) $row['coffee_revenue_cents'];
+ $unitPriceCents = $row['unit_price_cents'] !== null ? (int) $row['unit_price_cents'] : null;
+ $totalCents = $row['total_cents'] !== null ? (int) $row['total_cents'] : null;
+ $recordedAt = $row['recorded_at'] ?? $row['entry_created_at'];
+
+ $this->writeCsvRow($handle, [
+ (int) $row['booking_id'],
+ (string) $row['type'],
+ (string) $row['occurred_at'],
+ $row['member_id'] !== null ? (int) $row['member_id'] : '',
+ $row['member_name'],
+ (string) $row['description'],
+ $row['source_code'],
+ $row['source_name'],
+ $row['reference_type'],
+ $row['reference_id'],
+ $memberBalanceCents,
+ $this->formatMoneyFromCents($memberBalanceCents),
+ $cashboxCents,
+ $this->formatMoneyFromCents($cashboxCents),
+ $coffeeRevenueCents,
+ $this->formatMoneyFromCents($coffeeRevenueCents),
+ $row['product_id'] !== null ? (int) $row['product_id'] : '',
+ $row['product_name'],
+ $this->formatQuantity($row['quantity']),
+ $unitPriceCents ?? '',
+ $unitPriceCents !== null ? $this->formatMoneyFromCents($unitPriceCents) : '',
+ $totalCents ?? '',
+ $totalCents !== null ? $this->formatMoneyFromCents($totalCents) : '',
+ $row['notes'],
+ $row['effective_at'] ?? $row['occurred_at'],
+ $recordedAt,
+ $row['source_reference'],
+ $row['paper_sheet_line_id'],
+ (string) $row['entry_created_at'],
+ ]);
+
+ $rowCount++;
+ $this->flushIfNeeded($rowCount);
+ }
+ } finally {
+ fclose($handle);
+ }
+ }
+
+ private function assertTenantId(int $tenantId): void
+ {
+ if ($tenantId <= 0) {
+ throw new RuntimeException('Die Tenant-ID muss groesser als 0 sein.');
+ }
+ }
+
+ private function defaultFilename(string $prefix, int $tenantId): string
+ {
+ return sprintf('%s-tenant-%d-%s.csv', $prefix, $tenantId, gmdate('Ymd-His'));
+ }
+
+ private function openCsvOutput(string $filename)
+ {
+ if (headers_sent($file, $line)) {
+ throw new RuntimeException(sprintf('CSV-Header koennen nicht mehr gesendet werden (%s:%d).', $file, $line));
+ }
+
+ $safeFilename = $this->sanitizeFilename($filename);
+
+ header('Content-Type: text/csv; charset=UTF-8');
+ header('Content-Disposition: attachment; filename="' . $safeFilename . '"; filename*=UTF-8\'\'' . rawurlencode($safeFilename));
+ header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
+ header('Pragma: no-cache');
+ header('Expires: 0');
+ header('X-Accel-Buffering: no');
+ header('X-Content-Type-Options: nosniff');
+
+ $handle = fopen('php://output', 'wb');
+
+ if ($handle === false) {
+ throw new RuntimeException('php://output konnte nicht geoeffnet werden.');
+ }
+
+ fwrite($handle, "\xEF\xBB\xBF");
+
+ return $handle;
+ }
+
+ private function sanitizeFilename(string $filename): string
+ {
+ $sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '-', trim($filename)) ?? '';
+ $sanitized = trim($sanitized, '.-');
+
+ if ($sanitized === '') {
+ $sanitized = 'export';
+ }
+
+ if (!str_ends_with(strtolower($sanitized), '.csv')) {
+ $sanitized .= '.csv';
+ }
+
+ return $sanitized;
+ }
+
+ private function writeCsvRow($handle, array $row): void
+ {
+ $values = array_map(function (mixed $value): string {
+ return $this->sanitizeCsvValue($value);
+ }, $row);
+
+ if (fputcsv($handle, $values, self::CSV_DELIMITER) === false) {
+ throw new RuntimeException('Die CSV-Zeile konnte nicht geschrieben werden.');
+ }
+ }
+
+ private function sanitizeCsvValue(mixed $value): string
+ {
+ if ($value === null) {
+ return '';
+ }
+
+ if (is_bool($value)) {
+ return $value ? '1' : '0';
+ }
+
+ $string = str_replace(["\r\n", "\r"], "\n", (string) $value);
+
+ // Prevent spreadsheet formula execution for user-controlled text cells.
+ if (
+ $string !== ''
+ && in_array($string[0], ['=', '+', '-', '@', "\t"], true)
+ && !is_numeric($string)
+ ) {
+ return "'" . $string;
+ }
+
+ return $string;
+ }
+
+ private function formatMoneyFromCents(int $amountCents): string
+ {
+ return number_format($amountCents / 100, 2, ',', '.');
+ }
+
+ private function formatQuantity(mixed $quantity): string
+ {
+ if ($quantity === null || $quantity === '') {
+ return '';
+ }
+
+ return rtrim(rtrim(number_format((float) $quantity, 2, '.', ''), '0'), '.');
+ }
+
+ private function normalizeDateTimeFilter(?string $value, bool $endOfDay): ?string
+ {
+ $value = trim((string) $value);
+
+ if ($value === '') {
+ return null;
+ }
+
+ $value = str_replace('T', ' ', $value);
+
+ if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
+ return $value . ($endOfDay ? ' 23:59:59' : ' 00:00:00');
+ }
+
+ if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value) === 1) {
+ return $value . ':00';
+ }
+
+ return $value;
+ }
+
+ private function flushIfNeeded(int $rowCount): void
+ {
+ if ($rowCount % self::FLUSH_EVERY_ROWS !== 0) {
+ return;
+ }
+
+ if (ob_get_level() > 0) {
+ ob_flush();
+ }
+
+ flush();
+ }
+}
diff --git a/app/Services/LedgerService.php b/app/Services/LedgerService.php
index 6c0bbb3..7ec84a6 100644
--- a/app/Services/LedgerService.php
+++ b/app/Services/LedgerService.php
@@ -29,6 +29,9 @@ final class LedgerService
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
}
+ $this->assertTenantMembership($tenantId, $recordedByUserId);
+ $this->assertTenantMember($tenantId, $memberId);
+ $this->assertTenantProduct($tenantId, $productId);
$source = $this->findSource($tenantId, $sourceCode);
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
$totalCents = (int) round($priceCents * $quantity);
@@ -179,6 +182,8 @@ final class LedgerService
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
}
+ $this->assertTenantMembership($tenantId, $recordedByUserId);
+ $this->assertTenantMember($tenantId, $memberId);
$source = $this->findSource($tenantId, 'admin_backoffice');
$entryInsert = $this->pdo->prepare(
@@ -270,6 +275,12 @@ final class LedgerService
?string $notes,
int $createdByUserId
): int {
+ $this->assertTenantMembership($tenantId, $createdByUserId);
+
+ if (trim($title) === '') {
+ throw new RuntimeException('Bitte einen Titel fuer die Papierliste angeben.');
+ }
+
$statement = $this->pdo->prepare(
'INSERT INTO paper_sheets (
tenant_id,
@@ -320,6 +331,10 @@ final class LedgerService
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
}
+ $this->assertTenantMembership($tenantId, $actorUserId);
+ $this->assertTenantMember($tenantId, $memberId);
+ $this->assertTenantProduct($tenantId, $productId);
+
$sheet = $this->pdo->prepare(
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
);
@@ -375,6 +390,8 @@ final class LedgerService
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
{
+ $this->assertTenantMembership($tenantId, $actorUserId);
+
$sheetStatement = $this->pdo->prepare(
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
);
@@ -432,6 +449,170 @@ final class LedgerService
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
}
+ public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int
+ {
+ $this->assertTenantMembership($tenantId, $actorUserId);
+
+ $entry = $this->ledgerEntry($tenantId, $ledgerEntryId);
+
+ if ($this->hasReversal($tenantId, $ledgerEntryId)) {
+ throw new RuntimeException('Dieser Vorgang wurde bereits storniert.');
+ }
+
+ $linesStatement = $this->pdo->prepare(
+ 'SELECT member_id, account_code, amount_cents
+ FROM ledger_lines
+ WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id
+ ORDER BY id ASC'
+ );
+ $linesStatement->execute([
+ 'tenant_id' => $tenantId,
+ 'ledger_entry_id' => $ledgerEntryId,
+ ]);
+ $lines = $linesStatement->fetchAll();
+
+ if ($lines === []) {
+ throw new RuntimeException('Es wurden keine Ledger-Zeilen fuer den Vorgang gefunden.');
+ }
+
+ $description = 'Storno: ' . $entry['description'];
+
+ if ($reason !== null && trim($reason) !== '') {
+ $description .= ' (' . trim($reason) . ')';
+ }
+
+ $this->pdo->beginTransaction();
+
+ try {
+ $entryInsert = $this->pdo->prepare(
+ 'INSERT INTO ledger_entries (
+ tenant_id,
+ member_id,
+ source_id,
+ type,
+ reference_type,
+ reference_id,
+ description,
+ occurred_at,
+ created_by_user_id,
+ reversal_of_entry_id
+ ) VALUES (
+ :tenant_id,
+ :member_id,
+ :source_id,
+ "correction",
+ "ledger_reversal",
+ :reference_id,
+ :description,
+ :occurred_at,
+ :created_by_user_id,
+ :reversal_of_entry_id
+ )'
+ );
+ $entryInsert->execute([
+ 'tenant_id' => $tenantId,
+ 'member_id' => $entry['member_id'],
+ 'source_id' => $entry['source_id'],
+ 'reference_id' => $ledgerEntryId,
+ 'description' => $description,
+ 'occurred_at' => gmdate('Y-m-d H:i:s'),
+ 'created_by_user_id' => $actorUserId,
+ 'reversal_of_entry_id' => $ledgerEntryId,
+ ]);
+ $reversalId = (int) $this->pdo->lastInsertId();
+
+ $lineInsert = $this->pdo->prepare(
+ 'INSERT INTO ledger_lines (
+ ledger_entry_id,
+ tenant_id,
+ member_id,
+ account_code,
+ amount_cents
+ ) VALUES (
+ :ledger_entry_id,
+ :tenant_id,
+ :member_id,
+ :account_code,
+ :amount_cents
+ )'
+ );
+
+ foreach ($lines as $line) {
+ $lineInsert->execute([
+ 'ledger_entry_id' => $reversalId,
+ 'tenant_id' => $tenantId,
+ 'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null,
+ 'account_code' => $line['account_code'],
+ 'amount_cents' => 0 - (int) $line['amount_cents'],
+ ]);
+ }
+
+ $this->pdo->commit();
+ } catch (\Throwable $throwable) {
+ $this->pdo->rollBack();
+ throw $throwable;
+ }
+
+ $this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [
+ 'reversal_of_entry_id' => $ledgerEntryId,
+ ]);
+
+ return $reversalId;
+ }
+
+ public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int
+ {
+ $this->assertTenantMembership($tenantId, $actorUserId);
+
+ $statement = $this->pdo->prepare(
+ 'SELECT DISTINCT le.id
+ FROM ledger_entries le
+ INNER JOIN consumption_events ce
+ ON ce.id = le.reference_id
+ AND le.reference_type = "consumption_event"
+ INNER JOIN paper_sheet_lines psl
+ ON psl.id = ce.paper_sheet_line_id
+ WHERE le.tenant_id = :tenant_id
+ AND psl.paper_sheet_id = :paper_sheet_id
+ ORDER BY le.id ASC'
+ );
+ $statement->execute([
+ 'tenant_id' => $tenantId,
+ 'paper_sheet_id' => $sheetId,
+ ]);
+ $entryIds = $statement->fetchAll(PDO::FETCH_COLUMN);
+
+ if ($entryIds === []) {
+ throw new RuntimeException('Zu dieser Papierliste wurden keine verbuchten Eintraege gefunden.');
+ }
+
+ $reversed = 0;
+
+ foreach ($entryIds as $entryId) {
+ if ($this->hasReversal($tenantId, (int) $entryId)) {
+ continue;
+ }
+
+ $this->reverseLedgerEntry(
+ $tenantId,
+ (int) $entryId,
+ $actorUserId,
+ $reason !== null && trim($reason) !== '' ? $reason : 'Papierliste #' . $sheetId
+ );
+ $reversed++;
+ }
+
+ if ($reversed === 0) {
+ throw new RuntimeException('Diese Papierliste wurde bereits vollstaendig storniert.');
+ }
+
+ $this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [
+ 'reversed_entries' => $reversed,
+ ]);
+
+ return $reversed;
+ }
+
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
{
$statement = $this->pdo->prepare(
@@ -475,4 +656,93 @@ final class LedgerService
return $source;
}
+
+ private function assertTenantMember(int $tenantId, int $memberId): void
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1'
+ );
+ $statement->execute([
+ 'id' => $memberId,
+ 'tenant_id' => $tenantId,
+ ]);
+
+ if (!$statement->fetchColumn()) {
+ throw new RuntimeException('Das gewaehlte Mitglied gehoert nicht zu diesem Mandanten.');
+ }
+ }
+
+ private function assertTenantProduct(int $tenantId, int $productId): void
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1'
+ );
+ $statement->execute([
+ 'id' => $productId,
+ 'tenant_id' => $tenantId,
+ ]);
+
+ if (!$statement->fetchColumn()) {
+ throw new RuntimeException('Das gewaehlte Produkt gehoert nicht zu diesem Mandanten.');
+ }
+ }
+
+ private function assertTenantMembership(int $tenantId, int $userId): void
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id
+ FROM tenant_memberships
+ WHERE tenant_id = :tenant_id
+ AND user_id = :user_id
+ AND status = "active"
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'tenant_id' => $tenantId,
+ 'user_id' => $userId,
+ ]);
+
+ if (!$statement->fetchColumn()) {
+ throw new RuntimeException('Der Benutzer ist fuer diesen Mandanten nicht aktiv.');
+ }
+ }
+
+ private function ledgerEntry(int $tenantId, int $ledgerEntryId): array
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT *
+ FROM ledger_entries
+ WHERE id = :id
+ AND tenant_id = :tenant_id
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'id' => $ledgerEntryId,
+ 'tenant_id' => $tenantId,
+ ]);
+ $entry = $statement->fetch();
+
+ if (!$entry) {
+ throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.');
+ }
+
+ return $entry;
+ }
+
+ private function hasReversal(int $tenantId, int $ledgerEntryId): bool
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id
+ FROM ledger_entries
+ WHERE tenant_id = :tenant_id
+ AND reversal_of_entry_id = :reversal_of_entry_id
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'tenant_id' => $tenantId,
+ 'reversal_of_entry_id' => $ledgerEntryId,
+ ]);
+
+ return (bool) $statement->fetchColumn();
+ }
}
diff --git a/app/Services/MailerService.php b/app/Services/MailerService.php
new file mode 100644
index 0000000..3eea101
--- /dev/null
+++ b/app/Services/MailerService.php
@@ -0,0 +1,177 @@
+rootPath = dirname(__DIR__, 2);
+ }
+
+ public function send(string $to, string $subject, string $textBody, array $headers = []): bool
+ {
+ $to = trim($to);
+ $subject = trim($subject);
+ $textBody = trim($textBody);
+
+ if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Die Zieladresse fuer den Mailversand ist ungueltig.');
+ }
+
+ if ($subject === '' || $textBody === '') {
+ throw new RuntimeException('Betreff und Inhalt fuer den Mailversand sind erforderlich.');
+ }
+
+ $subjectLine = $this->encodeSubject($this->sanitizeHeaderValue($this->subjectPrefix() . $subject));
+ $headerLines = $this->buildHeaders($headers);
+ $body = $this->normalizeBody($textBody);
+
+ $sent = false;
+
+ if (function_exists('mail')) {
+ $sent = @mail($to, $subjectLine, $body, implode("\r\n", $headerLines));
+ }
+
+ if ($sent) {
+ return true;
+ }
+
+ if (!$this->usesLogFallback()) {
+ throw new RuntimeException('Die E-Mail konnte nicht versendet werden.');
+ }
+
+ $this->writeFallbackLog([
+ 'to' => $to,
+ 'subject' => $this->subjectPrefix() . $subject,
+ 'headers' => $headerLines,
+ 'body' => $textBody,
+ ]);
+
+ return false;
+ }
+
+ private function buildHeaders(array $headers): array
+ {
+ $headerLines = [
+ 'MIME-Version: 1.0',
+ 'Content-Type: text/plain; charset=UTF-8',
+ 'Content-Transfer-Encoding: 8bit',
+ 'X-Mailer: Kaffeeliste-Neustart',
+ ];
+
+ $from = trim((string) ($this->config['from'] ?? ''));
+
+ if ($from !== '') {
+ $headerLines[] = 'From: ' . $this->sanitizeHeaderValue($from);
+ }
+
+ $replyTo = trim((string) ($this->config['reply_to'] ?? ''));
+
+ if ($replyTo !== '') {
+ $headerLines[] = 'Reply-To: ' . $this->sanitizeHeaderValue($replyTo);
+ }
+
+ foreach ($headers as $name => $value) {
+ if (is_int($name)) {
+ $line = trim((string) $value);
+
+ if ($line !== '' && str_contains($line, ':')) {
+ [$rawName, $rawValue] = array_pad(explode(':', $line, 2), 2, '');
+ $this->appendHeader($headerLines, $rawName, $rawValue);
+ }
+
+ continue;
+ }
+
+ $this->appendHeader($headerLines, (string) $name, (string) $value);
+ }
+
+ return $headerLines;
+ }
+
+ private function appendHeader(array &$headerLines, string $name, string $value): void
+ {
+ $name = trim($name);
+
+ if ($name === '' || preg_match('/[^A-Za-z0-9-]/', $name)) {
+ return;
+ }
+
+ $headerLines[] = $name . ': ' . $this->sanitizeHeaderValue($value);
+ }
+
+ private function writeFallbackLog(array $payload): void
+ {
+ $path = $this->resolveLogPath();
+ $directory = dirname($path);
+
+ if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
+ throw new RuntimeException('Das Mail-Log-Verzeichnis konnte nicht erstellt werden.');
+ }
+
+ $encodedPayload = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+ if (!is_string($encodedPayload)) {
+ $encodedPayload = '{"error":"mail payload could not be encoded"}';
+ }
+
+ $entry = '[' . gmdate(DATE_ATOM) . "] mail_fallback\n" . $encodedPayload . "\n\n";
+
+ if (file_put_contents($path, $entry, FILE_APPEND | LOCK_EX) === false) {
+ throw new RuntimeException('Das Mail-Fallback-Log konnte nicht geschrieben werden.');
+ }
+ }
+
+ private function resolveLogPath(): string
+ {
+ $configuredPath = trim((string) ($this->config['log_file'] ?? ''));
+
+ if ($configuredPath === '') {
+ return $this->rootPath . '/storage/logs/mail.log';
+ }
+
+ if (str_starts_with($configuredPath, '/')) {
+ return $configuredPath;
+ }
+
+ return $this->rootPath . '/' . ltrim($configuredPath, '/');
+ }
+
+ private function usesLogFallback(): bool
+ {
+ return (bool) ($this->config['log_fallback'] ?? true);
+ }
+
+ private function subjectPrefix(): string
+ {
+ return (string) ($this->config['subject_prefix'] ?? '');
+ }
+
+ private function sanitizeHeaderValue(string $value): string
+ {
+ return trim(str_replace(["\r", "\n"], ' ', $value));
+ }
+
+ private function encodeSubject(string $subject): string
+ {
+ if ($subject === '') {
+ return $subject;
+ }
+
+ if (function_exists('mb_encode_mimeheader')) {
+ return mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
+ }
+
+ return $subject;
+ }
+
+ private function normalizeBody(string $textBody): string
+ {
+ return str_replace("\n", "\r\n", str_replace(["\r\n", "\r"], "\n", $textBody));
+ }
+}
diff --git a/app/Services/PasswordResetService.php b/app/Services/PasswordResetService.php
new file mode 100644
index 0000000..55aa88d
--- /dev/null
+++ b/app/Services/PasswordResetService.php
@@ -0,0 +1,445 @@
+utc = new DateTimeZone('UTC');
+ }
+
+ public function requestReset(string $email, array $context = []): void
+ {
+ $email = $this->normalizeEmail($email);
+
+ if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return;
+ }
+
+ $user = $this->findUserByEmail($email);
+
+ if (!$user) {
+ return;
+ }
+
+ $token = $this->createTokenForUser((int) $user['id'], $context);
+ $this->sendResetMail($token);
+ }
+
+ public function createTokenForUser(int $userId, array $context = []): array
+ {
+ if ($userId <= 0) {
+ throw new RuntimeException('Der Benutzer fuer den Passwort-Reset ist ungueltig.');
+ }
+
+ $user = $this->findUserById($userId);
+
+ if (!$user) {
+ throw new RuntimeException('Der Benutzer fuer den Passwort-Reset wurde nicht gefunden.');
+ }
+
+ $nowTs = time();
+ $now = $this->formatTimestamp($nowTs);
+ $expiresAt = $this->formatTimestamp($nowTs + ($this->ttlMinutes() * 60));
+ $token = bin2hex(random_bytes(32));
+ $tokenHash = hash('sha256', $token);
+ $ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
+ $userAgent = trim((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
+
+ $this->pdo->beginTransaction();
+
+ try {
+ $cleanupStatement = $this->pdo->prepare(
+ 'DELETE FROM password_reset_tokens
+ WHERE user_id = :user_id
+ AND (consumed_at IS NOT NULL OR expires_at < :now)'
+ );
+ $cleanupStatement->execute([
+ 'user_id' => $userId,
+ 'now' => $now,
+ ]);
+
+ $insertStatement = $this->pdo->prepare(
+ 'INSERT INTO password_reset_tokens (
+ user_id,
+ email,
+ token_hash,
+ requested_ip_hash,
+ requested_user_agent_hash,
+ expires_at,
+ consumed_at,
+ created_at
+ ) VALUES (
+ :user_id,
+ :email,
+ :token_hash,
+ :requested_ip_hash,
+ :requested_user_agent_hash,
+ :expires_at,
+ NULL,
+ :created_at
+ )'
+ );
+ $insertStatement->execute([
+ 'user_id' => $userId,
+ 'email' => $user['email'],
+ 'token_hash' => $tokenHash,
+ 'requested_ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
+ 'requested_user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
+ 'expires_at' => $expiresAt,
+ 'created_at' => $now,
+ ]);
+
+ $trimStatement = $this->pdo->prepare(
+ 'SELECT id
+ FROM password_reset_tokens
+ WHERE user_id = :user_id
+ AND consumed_at IS NULL
+ AND expires_at >= :now
+ ORDER BY created_at DESC, id DESC'
+ );
+ $trimStatement->execute([
+ 'user_id' => $userId,
+ 'now' => $now,
+ ]);
+
+ $tokenIds = array_map(
+ static fn (array $row): int => (int) $row['id'],
+ $trimStatement->fetchAll()
+ );
+
+ $idsToDelete = array_slice($tokenIds, $this->maxActiveTokens());
+
+ if ($idsToDelete !== []) {
+ $deleteStatement = $this->pdo->prepare(
+ 'DELETE FROM password_reset_tokens
+ WHERE user_id = :user_id
+ AND id = :id'
+ );
+
+ foreach ($idsToDelete as $tokenId) {
+ $deleteStatement->execute([
+ 'user_id' => $userId,
+ 'id' => $tokenId,
+ ]);
+ }
+ }
+
+ $this->pdo->commit();
+ } catch (\Throwable $throwable) {
+ if ($this->pdo->inTransaction()) {
+ $this->pdo->rollBack();
+ }
+
+ throw $throwable;
+ }
+
+ return [
+ 'user_id' => $userId,
+ 'email' => $user['email'],
+ 'full_name' => $user['full_name'],
+ 'token' => $token,
+ 'expires_at' => $expiresAt,
+ 'reset_url' => $this->buildResetUrl($token, $context),
+ ];
+ }
+
+ public function findValidToken(string $token): ?array
+ {
+ $token = trim($token);
+
+ if ($token === '') {
+ return null;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'SELECT prt.id,
+ prt.user_id,
+ prt.email,
+ prt.expires_at,
+ prt.consumed_at,
+ prt.created_at,
+ u.full_name
+ FROM password_reset_tokens prt
+ INNER JOIN users u ON u.id = prt.user_id
+ WHERE prt.token_hash = :token_hash
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'token_hash' => hash('sha256', $token),
+ ]);
+ $resetToken = $statement->fetch();
+
+ if (!$resetToken) {
+ return null;
+ }
+
+ $expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
+
+ if (
+ $resetToken['consumed_at'] !== null
+ || $expiresAtTs === null
+ || $expiresAtTs < time()
+ ) {
+ return null;
+ }
+
+ return $resetToken;
+ }
+
+ public function resetPassword(string $token, string $newPassword): int
+ {
+ $token = trim($token);
+ $newPassword = (string) $newPassword;
+
+ if ($token === '') {
+ throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
+ }
+
+ if (mb_strlen($newPassword) < 12) {
+ throw new RuntimeException('Das neue Passwort muss mindestens 12 Zeichen lang sein.');
+ }
+
+ $tokenHash = hash('sha256', $token);
+ $nowTs = time();
+ $now = $this->formatTimestamp($nowTs);
+
+ $this->pdo->beginTransaction();
+
+ try {
+ $statement = $this->pdo->prepare(
+ 'SELECT id, user_id, expires_at, consumed_at
+ FROM password_reset_tokens
+ WHERE token_hash = :token_hash
+ LIMIT 1
+ FOR UPDATE'
+ );
+ $statement->execute(['token_hash' => $tokenHash]);
+ $resetToken = $statement->fetch();
+
+ if (!$resetToken) {
+ throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
+ }
+
+ $expiresAtTs = $this->timestampFromDatabase($resetToken['expires_at']);
+
+ if (
+ $resetToken['consumed_at'] !== null
+ || $expiresAtTs === null
+ || $expiresAtTs < $nowTs
+ ) {
+ throw new RuntimeException('Der Reset-Link ist ungueltig oder abgelaufen.');
+ }
+
+ $updateUserStatement = $this->pdo->prepare(
+ 'UPDATE users
+ SET password_hash = :password_hash
+ WHERE id = :user_id'
+ );
+ $updateUserStatement->execute([
+ 'password_hash' => secure_password_hash($newPassword),
+ 'user_id' => $resetToken['user_id'],
+ ]);
+
+ $consumeTokenStatement = $this->pdo->prepare(
+ 'UPDATE password_reset_tokens
+ SET consumed_at = :consumed_at
+ WHERE id = :id'
+ );
+ $consumeTokenStatement->execute([
+ 'consumed_at' => $now,
+ 'id' => $resetToken['id'],
+ ]);
+
+ $revokeOtherTokensStatement = $this->pdo->prepare(
+ 'DELETE FROM password_reset_tokens
+ WHERE user_id = :user_id
+ AND id <> :id'
+ );
+ $revokeOtherTokensStatement->execute([
+ 'user_id' => $resetToken['user_id'],
+ 'id' => $resetToken['id'],
+ ]);
+
+ $this->pdo->commit();
+ } catch (\Throwable $throwable) {
+ if ($this->pdo->inTransaction()) {
+ $this->pdo->rollBack();
+ }
+
+ throw $throwable;
+ }
+
+ return (int) $resetToken['user_id'];
+ }
+
+ public function revokeTokensForUser(int $userId): int
+ {
+ if ($userId <= 0) {
+ return 0;
+ }
+
+ $statement = $this->pdo->prepare(
+ 'DELETE FROM password_reset_tokens
+ WHERE user_id = :user_id'
+ );
+ $statement->execute(['user_id' => $userId]);
+
+ return $statement->rowCount();
+ }
+
+ public function purgeExpiredTokens(): int
+ {
+ $statement = $this->pdo->prepare(
+ 'DELETE FROM password_reset_tokens
+ WHERE consumed_at IS NOT NULL
+ OR expires_at < :now'
+ );
+ $statement->execute(['now' => $this->formatTimestamp(time())]);
+
+ return $statement->rowCount();
+ }
+
+ private function sendResetMail(array $token): void
+ {
+ $name = trim((string) ($token['full_name'] ?? ''));
+ $greeting = $name !== '' ? 'Hallo ' . $name . ',' : 'Hallo,';
+ $body = implode("\n\n", [
+ $greeting,
+ 'fuer dein Konto wurde ein Passwort-Reset angefragt.',
+ 'Bitte nutze diesen Link, um ein neues Passwort zu setzen:',
+ (string) $token['reset_url'],
+ 'Der Link ist gueltig bis ' . (string) $token['expires_at'] . ' UTC.',
+ 'Falls du den Reset nicht angefragt hast, kannst du diese Nachricht ignorieren.',
+ ]);
+
+ $this->mailer->send(
+ (string) $token['email'],
+ $this->mailSubject(),
+ $body
+ );
+ }
+
+ private function findUserByEmail(string $email): ?array
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id, full_name, email
+ FROM users
+ WHERE email = :email
+ LIMIT 1'
+ );
+ $statement->execute(['email' => $email]);
+ $user = $statement->fetch();
+
+ return $user ?: null;
+ }
+
+ private function findUserById(int $userId): ?array
+ {
+ $statement = $this->pdo->prepare(
+ 'SELECT id, full_name, email
+ FROM users
+ WHERE id = :id
+ LIMIT 1'
+ );
+ $statement->execute(['id' => $userId]);
+ $user = $statement->fetch();
+
+ return $user ?: null;
+ }
+
+ private function normalizeEmail(string $email): string
+ {
+ return mb_strtolower(trim($email));
+ }
+
+ private function buildResetUrl(string $token, array $context = []): string
+ {
+ $template = trim((string) ($this->config['reset_url'] ?? ''));
+
+ if ($template === '') {
+ $template = 'http://localhost:8080/reset-password?token={{token}}';
+ }
+
+ $query = [];
+
+ foreach ($context as $key => $value) {
+ if (!is_string($key) || trim($key) === '' || !is_scalar($value)) {
+ continue;
+ }
+
+ $normalizedValue = trim((string) $value);
+
+ if ($normalizedValue === '') {
+ continue;
+ }
+
+ $query[$key] = $normalizedValue;
+ }
+
+ if (str_contains($template, '{{token}}')) {
+ $url = str_replace('{{token}}', rawurlencode($token), $template);
+
+ if ($query === []) {
+ return $url;
+ }
+
+ $separator = str_contains($url, '?') ? '&' : '?';
+
+ return $url . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
+ }
+
+ $query['token'] = $token;
+ $separator = str_contains($template, '?') ? '&' : '?';
+
+ return $template . $separator . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
+ }
+
+ private function mailSubject(): string
+ {
+ $subject = trim((string) ($this->config['subject'] ?? ''));
+
+ return $subject !== '' ? $subject : 'Passwort zuruecksetzen';
+ }
+
+ private function ttlMinutes(): int
+ {
+ return max(5, (int) ($this->config['ttl_minutes'] ?? 60));
+ }
+
+ private function maxActiveTokens(): int
+ {
+ return max(1, (int) ($this->config['max_active_tokens'] ?? 3));
+ }
+
+ private function formatTimestamp(int $timestamp): string
+ {
+ return gmdate('Y-m-d H:i:s', $timestamp);
+ }
+
+ private function timestampFromDatabase(?string $value): ?int
+ {
+ if ($value === null || trim($value) === '') {
+ return null;
+ }
+
+ $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
+
+ if (!$date instanceof DateTimeImmutable) {
+ return null;
+ }
+
+ return $date->getTimestamp();
+ }
+}
diff --git a/app/Services/RateLimiterService.php b/app/Services/RateLimiterService.php
new file mode 100644
index 0000000..d495e3a
--- /dev/null
+++ b/app/Services/RateLimiterService.php
@@ -0,0 +1,272 @@
+utc = new DateTimeZone('UTC');
+ }
+
+ public function inspect(string $action, string $scope): array
+ {
+ $policy = $this->resolvePolicy($action);
+ $statement = $this->pdo->prepare(
+ 'SELECT attempts, window_start, last_attempt_at, blocked_until
+ FROM rate_limits
+ WHERE action_key = :action_key
+ AND bucket_key = :bucket_key
+ LIMIT 1'
+ );
+ $statement->execute([
+ 'action_key' => $policy['action'],
+ 'bucket_key' => $this->bucketKey($scope),
+ ]);
+
+ return $this->buildState($policy, $statement->fetch() ?: null, time());
+ }
+
+ public function hit(string $action, string $scope): array
+ {
+ $policy = $this->resolvePolicy($action);
+ $bucketKey = $this->bucketKey($scope);
+ $nowTs = time();
+ $now = $this->formatTimestamp($nowTs);
+
+ $this->pdo->beginTransaction();
+
+ try {
+ $bootstrapStatement = $this->pdo->prepare(
+ 'INSERT INTO rate_limits (
+ action_key,
+ bucket_key,
+ attempts,
+ window_start,
+ last_attempt_at,
+ blocked_until,
+ created_at
+ ) VALUES (
+ :action_key,
+ :bucket_key,
+ 0,
+ :window_start,
+ :last_attempt_at,
+ NULL,
+ :created_at
+ )
+ ON DUPLICATE KEY UPDATE
+ action_key = action_key'
+ );
+ $bootstrapStatement->execute([
+ 'action_key' => $policy['action'],
+ 'bucket_key' => $bucketKey,
+ 'window_start' => $now,
+ 'last_attempt_at' => $now,
+ 'created_at' => $now,
+ ]);
+
+ $selectStatement = $this->pdo->prepare(
+ 'SELECT attempts, window_start, last_attempt_at, blocked_until
+ FROM rate_limits
+ WHERE action_key = :action_key
+ AND bucket_key = :bucket_key
+ LIMIT 1
+ FOR UPDATE'
+ );
+ $selectStatement->execute([
+ 'action_key' => $policy['action'],
+ 'bucket_key' => $bucketKey,
+ ]);
+ $row = $selectStatement->fetch();
+
+ if (!$row) {
+ throw new RuntimeException('Der Rate-Limit-Zustand konnte nicht geladen werden.');
+ }
+
+ $windowStartTs = $this->timestampFromDatabase($row['window_start']);
+ $blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
+ $isBlocked = $blockedUntilTs !== null && $blockedUntilTs > $nowTs;
+ $resetWindow = $windowStartTs === null
+ || ($windowStartTs + $policy['window_seconds']) <= $nowTs
+ || ($blockedUntilTs !== null && $blockedUntilTs <= $nowTs);
+
+ if ($isBlocked) {
+ $updatedRow = $row;
+ } else {
+ $attempts = $resetWindow ? 1 : ((int) $row['attempts'] + 1);
+ $windowStart = $resetWindow ? $now : (string) $row['window_start'];
+ $blockedUntil = $attempts >= $policy['max_attempts']
+ ? $this->formatTimestamp($nowTs + $policy['block_seconds'])
+ : null;
+
+ $updateStatement = $this->pdo->prepare(
+ 'UPDATE rate_limits
+ SET attempts = :attempts,
+ window_start = :window_start,
+ last_attempt_at = :last_attempt_at,
+ blocked_until = :blocked_until
+ WHERE action_key = :action_key
+ AND bucket_key = :bucket_key'
+ );
+ $updateStatement->execute([
+ 'attempts' => $attempts,
+ 'window_start' => $windowStart,
+ 'last_attempt_at' => $now,
+ 'blocked_until' => $blockedUntil,
+ 'action_key' => $policy['action'],
+ 'bucket_key' => $bucketKey,
+ ]);
+
+ $updatedRow = [
+ 'attempts' => $attempts,
+ 'window_start' => $windowStart,
+ 'last_attempt_at' => $now,
+ 'blocked_until' => $blockedUntil,
+ ];
+ }
+
+ $this->pdo->commit();
+ } catch (\Throwable $throwable) {
+ if ($this->pdo->inTransaction()) {
+ $this->pdo->rollBack();
+ }
+
+ throw $throwable;
+ }
+
+ return $this->buildState($policy, $updatedRow, $nowTs);
+ }
+
+ public function clear(string $action, string $scope): void
+ {
+ $policy = $this->resolvePolicy($action);
+ $statement = $this->pdo->prepare(
+ 'DELETE FROM rate_limits
+ WHERE action_key = :action_key
+ AND bucket_key = :bucket_key'
+ );
+ $statement->execute([
+ 'action_key' => $policy['action'],
+ 'bucket_key' => $this->bucketKey($scope),
+ ]);
+ }
+
+ public function tooManyAttempts(string $action, string $scope): bool
+ {
+ return !$this->inspect($action, $scope)['allowed'];
+ }
+
+ public function prune(int $staleAfterSeconds = 604800): int
+ {
+ $cutoff = $this->formatTimestamp(time() - max(3600, $staleAfterSeconds));
+ $statement = $this->pdo->prepare(
+ 'DELETE FROM rate_limits
+ WHERE (blocked_until IS NULL AND last_attempt_at < :cutoff)
+ OR (blocked_until IS NOT NULL AND blocked_until < :cutoff)'
+ );
+ $statement->execute(['cutoff' => $cutoff]);
+
+ return $statement->rowCount();
+ }
+
+ private function buildState(array $policy, ?array $row, int $nowTs): array
+ {
+ $attempts = 0;
+ $blockedUntil = null;
+ $retryAfter = 0;
+ $allowed = true;
+ $resetAtTs = $nowTs + $policy['window_seconds'];
+
+ if ($row) {
+ $windowStartTs = $this->timestampFromDatabase($row['window_start']);
+ $blockedUntilTs = $this->timestampFromDatabase($row['blocked_until']);
+
+ if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
+ $attempts = (int) $row['attempts'];
+ $blockedUntil = (string) $row['blocked_until'];
+ $retryAfter = max(1, $blockedUntilTs - $nowTs);
+ $allowed = false;
+ $resetAtTs = $blockedUntilTs;
+ } elseif ($windowStartTs !== null && ($windowStartTs + $policy['window_seconds']) > $nowTs) {
+ $attempts = (int) $row['attempts'];
+ $resetAtTs = $windowStartTs + $policy['window_seconds'];
+ }
+ }
+
+ return [
+ 'action' => $policy['action'],
+ 'allowed' => $allowed,
+ 'attempts' => $attempts,
+ 'max_attempts' => $policy['max_attempts'],
+ 'remaining_attempts' => max(0, $policy['max_attempts'] - $attempts),
+ 'window_seconds' => $policy['window_seconds'],
+ 'block_seconds' => $policy['block_seconds'],
+ 'retry_after_seconds' => $retryAfter,
+ 'reset_at' => $this->formatTimestamp($resetAtTs),
+ 'blocked_until' => $blockedUntil,
+ ];
+ }
+
+ private function resolvePolicy(string $action): array
+ {
+ $normalizedAction = mb_strtolower(trim($action));
+
+ if ($normalizedAction === '') {
+ throw new RuntimeException('Die Rate-Limit-Aktion ist erforderlich.');
+ }
+
+ $policy = $this->config[$normalizedAction] ?? null;
+
+ if (!is_array($policy)) {
+ throw new RuntimeException(sprintf('Fuer die Aktion "%s" ist kein Rate-Limit konfiguriert.', $normalizedAction));
+ }
+
+ return [
+ 'action' => $normalizedAction,
+ 'max_attempts' => max(1, (int) ($policy['max_attempts'] ?? 5)),
+ 'window_seconds' => max(60, (int) ($policy['window_seconds'] ?? 900)),
+ 'block_seconds' => max(60, (int) ($policy['block_seconds'] ?? 900)),
+ ];
+ }
+
+ private function bucketKey(string $scope): string
+ {
+ $scope = trim($scope);
+
+ if ($scope === '') {
+ throw new RuntimeException('Der Rate-Limit-Scope ist erforderlich.');
+ }
+
+ return hash('sha256', $scope);
+ }
+
+ private function formatTimestamp(int $timestamp): string
+ {
+ return gmdate('Y-m-d H:i:s', $timestamp);
+ }
+
+ private function timestampFromDatabase(?string $value): ?int
+ {
+ if ($value === null || trim($value) === '') {
+ return null;
+ }
+
+ $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $this->utc);
+
+ if (!$date instanceof DateTimeImmutable) {
+ return null;
+ }
+
+ return $date->getTimestamp();
+ }
+}
diff --git a/app/Services/RfidService.php b/app/Services/RfidService.php
index bc8d9a3..bb835cd 100644
--- a/app/Services/RfidService.php
+++ b/app/Services/RfidService.php
@@ -22,7 +22,7 @@ final class RfidService
$token = bin2hex(random_bytes(20));
$statement = $this->pdo->prepare(
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
- VALUES (:tenant_id, :name, :location_label, :device_token, "planned")'
+ VALUES (:tenant_id, :name, :location_label, :device_token, "active")'
);
$statement->execute([
'tenant_id' => $tenantId,
@@ -43,6 +43,18 @@ final class RfidService
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
}
+ $memberStatement = $this->pdo->prepare(
+ 'SELECT id FROM members WHERE tenant_id = :tenant_id AND id = :id AND status = "active" LIMIT 1'
+ );
+ $memberStatement->execute([
+ 'tenant_id' => $tenantId,
+ 'id' => $memberId,
+ ]);
+
+ if (!$memberStatement->fetchColumn()) {
+ throw new RuntimeException('Das ausgewaehlte Mitglied ist fuer RFID nicht verfuegbar.');
+ }
+
$statement = $this->pdo->prepare(
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
@@ -80,6 +92,10 @@ final class RfidService
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
}
+ if (($device['status'] ?? '') !== 'active') {
+ throw new RuntimeException('Das RFID-Geraet ist nicht aktiv.');
+ }
+
$status = 'received';
$tagStatement = $this->pdo->prepare(
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
diff --git a/app/Services/SecurityHeadersService.php b/app/Services/SecurityHeadersService.php
new file mode 100644
index 0000000..079c95b
--- /dev/null
+++ b/app/Services/SecurityHeadersService.php
@@ -0,0 +1,97 @@
+isHttpsRequest();
+
+ $headers = [
+ 'Content-Security-Policy' => $this->buildContentSecurityPolicy($isHttps),
+ 'Referrer-Policy' => 'strict-origin-when-cross-origin',
+ 'Permissions-Policy' => 'accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()',
+ 'Cross-Origin-Opener-Policy' => 'same-origin',
+ 'Cross-Origin-Resource-Policy' => 'same-origin',
+ 'Origin-Agent-Cluster' => '?1',
+ 'X-Content-Type-Options' => 'nosniff',
+ 'X-Frame-Options' => 'SAMEORIGIN',
+ 'X-Permitted-Cross-Domain-Policies' => 'none',
+ ];
+
+ if ($isHttps) {
+ $headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
+ }
+
+ return $headers;
+ }
+
+ public function send(array $overrides = [], ?bool $isHttps = null): void
+ {
+ if (headers_sent()) {
+ return;
+ }
+
+ $headers = $this->buildHeaders($isHttps);
+
+ foreach ($overrides as $name => $value) {
+ if (!is_string($name) || trim($name) === '') {
+ continue;
+ }
+
+ if ($value === null) {
+ unset($headers[$name]);
+ continue;
+ }
+
+ $headers[$name] = (string) $value;
+ }
+
+ header_remove('X-Powered-By');
+
+ foreach ($headers as $name => $value) {
+ header($name . ': ' . $value, true);
+ }
+ }
+
+ private function buildContentSecurityPolicy(bool $isHttps): string
+ {
+ $directives = [
+ "default-src 'self'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ "frame-ancestors 'self'",
+ "object-src 'none'",
+ "connect-src 'self'",
+ "font-src 'self' data:",
+ "img-src 'self' data:",
+ "manifest-src 'self'",
+ "script-src 'self'",
+ "style-src 'self'",
+ ];
+
+ if ($isHttps) {
+ $directives[] = 'upgrade-insecure-requests';
+ }
+
+ return implode('; ', $directives);
+ }
+
+ private function isHttpsRequest(): bool
+ {
+ $https = $_SERVER['HTTPS'] ?? null;
+
+ if (is_string($https) && $https !== '' && strtolower($https) !== 'off') {
+ return true;
+ }
+
+ $forwardedProto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
+
+ if (is_string($forwardedProto) && strtolower($forwardedProto) === 'https') {
+ return true;
+ }
+
+ return (string) ($_SERVER['SERVER_PORT'] ?? '') === '443';
+ }
+}
diff --git a/app/Services/SetupService.php b/app/Services/SetupService.php
index 68b6551..331ac2f 100644
--- a/app/Services/SetupService.php
+++ b/app/Services/SetupService.php
@@ -43,6 +43,8 @@ final class SetupService
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
}
+ $this->resolveMailFrom($input, $this->normalizeAppUrl((string) $input['app_url']));
+
$config = [
'host' => trim((string) $input['db_host']),
'port' => (int) $input['db_port'],
@@ -106,6 +108,8 @@ final class SetupService
private function buildEnvFile(array $input): string
{
+ $appUrl = $this->normalizeAppUrl((string) $input['app_url']);
+ $mailFrom = $this->resolveMailFrom($input, $appUrl);
$appKey = bin2hex(random_bytes(32));
$rfidSecret = bin2hex(random_bytes(24));
@@ -113,9 +117,11 @@ final class SetupService
'APP_NAME="Kaffeekasse SaaS"',
'APP_ENV=production',
'APP_DEBUG=0',
- 'APP_URL=' . trim((string) $input['app_url']),
+ 'APP_URL=' . $appUrl,
'APP_TIMEZONE=Europe/Berlin',
'APP_KEY=' . $appKey,
+ 'ALLOW_PUBLIC_REGISTRATION=1',
+ 'RFID_INGEST_ENABLED=0',
'',
'DB_HOST=' . trim((string) $input['db_host']),
'DB_PORT=' . (int) $input['db_port'],
@@ -123,10 +129,64 @@ final class SetupService
'DB_USER=' . trim((string) $input['db_user']),
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
'',
- 'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
+ 'MAIL_FROM=' . $mailFrom,
+ 'MAIL_REPLY_TO=' . $mailFrom,
+ 'MAIL_SUBJECT_PREFIX="[Kaffeekasse] "',
+ 'MAIL_LOG_FALLBACK=1',
+ 'MAIL_LOG_FILE=storage/logs/mail.log',
+ '',
+ 'PASSWORD_RESET_URL=' . $appUrl . '/reset-password?token={{token}}',
+ 'PASSWORD_RESET_TTL_MINUTES=60',
+ 'PASSWORD_RESET_MAX_ACTIVE_TOKENS=3',
+ 'PASSWORD_RESET_SUBJECT="Passwort zuruecksetzen"',
+ '',
+ 'RATE_LIMIT_LOGIN_MAX_ATTEMPTS=5',
+ 'RATE_LIMIT_LOGIN_WINDOW_SECONDS=900',
+ 'RATE_LIMIT_LOGIN_BLOCK_SECONDS=900',
+ 'RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS=3',
+ 'RATE_LIMIT_REGISTRATION_WINDOW_SECONDS=3600',
+ 'RATE_LIMIT_REGISTRATION_BLOCK_SECONDS=3600',
+ '',
'RFID_SHARED_SECRET=' . $rfidSecret,
];
return implode(PHP_EOL, $lines) . PHP_EOL;
}
+
+ private function normalizeAppUrl(string $appUrl): string
+ {
+ return rtrim(trim($appUrl), '/');
+ }
+
+ private function resolveMailFrom(array $input, string $appUrl): string
+ {
+ $mailFrom = mb_strtolower(trim((string) ($input['mail_from'] ?? '')));
+
+ if ($mailFrom === '' || $mailFrom === 'noreply@example.com') {
+ return $this->defaultMailFrom($appUrl);
+ }
+
+ if (!filter_var($mailFrom, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Die Mail-Absenderadresse ist ungueltig.');
+ }
+
+ return $mailFrom;
+ }
+
+ private function defaultMailFrom(string $appUrl): string
+ {
+ $host = parse_url($appUrl, PHP_URL_HOST);
+
+ if (!is_string($host)) {
+ return 'noreply@example.com';
+ }
+
+ $host = preg_replace('/^www\./i', '', mb_strtolower(trim($host))) ?? '';
+
+ if ($host === '' || !str_contains($host, '.')) {
+ return 'noreply@example.com';
+ }
+
+ return 'noreply@' . $host;
+ }
}
diff --git a/bin/cron.php b/bin/cron.php
index 8c38b4d..1512b29 100644
--- a/bin/cron.php
+++ b/bin/cron.php
@@ -2,6 +2,10 @@
declare(strict_types=1);
+use App\Services\MailerService;
+use App\Services\PasswordResetService;
+use App\Services\RateLimiterService;
+
$rootPath = dirname(__DIR__);
$lockFile = $rootPath . '/storage/cron.lock';
@@ -20,6 +24,17 @@ try {
}
$pdo = $app['database']->pdo();
+ $passwordResetService = new PasswordResetService(
+ $pdo,
+ new MailerService($app['config']['mail'] ?? []),
+ $app['config']['password_reset'] ?? []
+ );
+ $rateLimiterService = new RateLimiterService(
+ $pdo,
+ $app['config']['rate_limits'] ?? []
+ );
+ $purgedPasswordResetTokens = $passwordResetService->purgeExpiredTokens();
+ $prunedRateLimits = $rateLimiterService->prune();
$pendingRfid = (int) $pdo->query(
'SELECT COUNT(*) FROM rfid_events WHERE status = "received"'
@@ -27,9 +42,14 @@ try {
$summary = [
'timestamp' => gmdate(DATE_ATOM),
+ 'cleanup' => [
+ 'password_reset_tokens_removed' => $purgedPasswordResetTokens,
+ 'rate_limit_entries_removed' => $prunedRateLimits,
+ ],
'pending_rfid_events' => $pendingRfid,
'notes' => [
- 'In diesem MVP dient Cron als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
+ 'Cron bereinigt abgelaufene Passwort-Reset-Tokens und veraltete Rate-Limit-Eintraege.',
+ 'In diesem MVP dient Cron ausserdem als technischer Haken fuer spaetere Reminder, Exporte und RFID-Regeln.',
],
];
diff --git a/config/app.php b/config/app.php
index ecb8d32..ba71e26 100644
--- a/config/app.php
+++ b/config/app.php
@@ -7,6 +7,8 @@ return [
'url' => rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/'),
'timezone' => $_ENV['APP_TIMEZONE'] ?? 'Europe/Berlin',
'app_key' => $_ENV['APP_KEY'] ?? '',
+ 'allow_public_registration' => (($_ENV['ALLOW_PUBLIC_REGISTRATION'] ?? '0') === '1'),
+ 'rfid_ingest_enabled' => (($_ENV['RFID_INGEST_ENABLED'] ?? '0') === '1'),
'db' => [
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => (int) ($_ENV['DB_PORT'] ?? 3306),
@@ -15,5 +17,31 @@ return [
'pass' => $_ENV['DB_PASS'] ?? '',
'charset' => 'utf8mb4',
],
+ 'mail' => [
+ 'from' => trim($_ENV['MAIL_FROM'] ?? 'noreply@example.com'),
+ 'reply_to' => trim($_ENV['MAIL_REPLY_TO'] ?? ''),
+ 'subject_prefix' => $_ENV['MAIL_SUBJECT_PREFIX'] ?? '[Kaffeekasse] ',
+ 'log_fallback' => (($_ENV['MAIL_LOG_FALLBACK'] ?? '1') === '1'),
+ 'log_file' => trim($_ENV['MAIL_LOG_FILE'] ?? ''),
+ ],
+ 'password_reset' => [
+ 'reset_url' => $_ENV['PASSWORD_RESET_URL']
+ ?? rtrim($_ENV['APP_URL'] ?? 'http://localhost:8080', '/') . '/reset-password?token={{token}}',
+ 'ttl_minutes' => max(5, (int) ($_ENV['PASSWORD_RESET_TTL_MINUTES'] ?? 60)),
+ 'max_active_tokens' => max(1, (int) ($_ENV['PASSWORD_RESET_MAX_ACTIVE_TOKENS'] ?? 3)),
+ 'subject' => $_ENV['PASSWORD_RESET_SUBJECT'] ?? 'Passwort zuruecksetzen',
+ ],
+ 'rate_limits' => [
+ 'login' => [
+ 'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_LOGIN_MAX_ATTEMPTS'] ?? 5)),
+ 'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_WINDOW_SECONDS'] ?? 900)),
+ 'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_LOGIN_BLOCK_SECONDS'] ?? 900)),
+ ],
+ 'registration' => [
+ 'max_attempts' => max(1, (int) ($_ENV['RATE_LIMIT_REGISTRATION_MAX_ATTEMPTS'] ?? 3)),
+ 'window_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_WINDOW_SECONDS'] ?? 3600)),
+ 'block_seconds' => max(60, (int) ($_ENV['RATE_LIMIT_REGISTRATION_BLOCK_SECONDS'] ?? 3600)),
+ ],
+ ],
'rfid_shared_secret' => $_ENV['RFID_SHARED_SECRET'] ?? '',
];
diff --git a/database/schema.sql b/database/schema.sql
index 6923ffc..d9e0515 100644
--- a/database/schema.sql
+++ b/database/schema.sql
@@ -7,6 +7,36 @@ CREATE TABLE IF NOT EXISTS users (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS password_reset_tokens (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ user_id BIGINT UNSIGNED NOT NULL,
+ email VARCHAR(190) NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE,
+ requested_ip_hash CHAR(64) NULL,
+ requested_user_agent_hash CHAR(64) NULL,
+ expires_at DATETIME NOT NULL,
+ consumed_at DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_password_reset_user_created (user_id, created_at),
+ INDEX idx_password_reset_expires (expires_at),
+ CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS rate_limits (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ action_key VARCHAR(80) NOT NULL,
+ bucket_key CHAR(64) NOT NULL,
+ attempts INT UNSIGNED NOT NULL DEFAULT 0,
+ window_start DATETIME NOT NULL,
+ last_attempt_at DATETIME NOT NULL,
+ blocked_until DATETIME NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uniq_rate_limit_bucket (action_key, bucket_key),
+ INDEX idx_rate_limit_blocked_until (blocked_until),
+ INDEX idx_rate_limit_last_attempt (last_attempt_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE IF NOT EXISTS tenants (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
@@ -57,7 +87,7 @@ CREATE TABLE IF NOT EXISTS members (
user_id BIGINT UNSIGNED NULL,
display_name VARCHAR(160) NOT NULL,
email VARCHAR(190) NULL,
- access_pin VARCHAR(20) NULL,
+ access_pin VARCHAR(255) NULL,
status ENUM('active', 'archived') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_member_email (tenant_id, email),
diff --git a/public/index.php b/public/index.php
index 0aee741..9a98888 100644
--- a/public/index.php
+++ b/public/index.php
@@ -5,9 +5,12 @@ declare(strict_types=1);
use App\Controllers\PlatformController;
use App\Controllers\TenantController;
use App\Core\Response;
+use App\Services\SecurityHeadersService;
$app = require dirname(__DIR__) . '/app/bootstrap.php';
+(new SecurityHeadersService())->send();
+
$platform = new PlatformController(
$app['rootPath'],
$app['config'],
@@ -21,7 +24,8 @@ $tenantController = new TenantController(
$app['database'],
$app['view'],
$app['session'],
- $app['csrf']
+ $app['csrf'],
+ $app['config']
);
$path = current_path();
@@ -48,6 +52,16 @@ try {
return;
}
+ if ($path === '/password/forgot') {
+ $method === 'POST' ? $platform->passwordResetRequestSubmit() : $platform->passwordResetRequestForm();
+ return;
+ }
+
+ if ($path === '/reset-password') {
+ $method === 'POST' ? $platform->passwordResetConfirmSubmit() : $platform->passwordResetConfirmForm();
+ return;
+ }
+
if ($path === '/admin' && $method === 'GET') {
$platform->adminDashboard();
return;
@@ -59,6 +73,11 @@ try {
}
if ($path === '/api/rfid/intake' && $method === 'POST') {
+ if (!($app['config']['rfid_ingest_enabled'] ?? false)) {
+ Response::notFound();
+ return;
+ }
+
$tenantController->rfidIngest();
return;
}
@@ -97,6 +116,16 @@ try {
return;
}
+ if ($subPath === 'exports/balances.csv' && $method === 'GET') {
+ $tenantController->exportBalances($tenantSlug);
+ return;
+ }
+
+ if ($subPath === 'exports/bookings.csv' && $method === 'GET') {
+ $tenantController->exportBookings($tenantSlug);
+ return;
+ }
+
if ($subPath === 'payments') {
$tenantController->payments($tenantSlug);
return;
diff --git a/resources/views/admin/login.php b/resources/views/admin/login.php
index c4311c8..1e17f7f 100644
--- a/resources/views/admin/login.php
+++ b/resources/views/admin/login.php
@@ -1,22 +1,39 @@
-
+
Fuer Betrieb, Mandantenuebersicht, Support und spaetere Vermarktungssteuerung. Fuer Pilotbetreuung, Mandantenuebersicht und Betriebsaufgaben. Mitglieder nutzen dafuer nicht diese Seite, sondern den jeweiligen Standort-Login.Admin-Login
-
+ = e($hasValidResetToken + ? ($scope === 'platform' + ? 'Der Plattformzugang bekommt hier sein neues Passwort.' + : 'Der Standortzugang fuer ' . $tenantName . ' bekommt hier sein neues Passwort.') + : 'Hier wird nur mit einem gueltigen Reset-Link ein neues Passwort gesetzt.') ?> + So bleibt der Login-Reset von PIN, RFID und sonstigen Standortdaten getrennt. +
++ = e($token === '' + ? 'Oeffne den Reset-Link aus der E-Mail, um hier ein neues Passwort zu setzen.' + : 'Dieser Reset-Link ist ungueltig oder abgelaufen. Bitte fordere einen neuen Link an.') ?> +
+ + ++ = e($scope === 'platform' + ? 'Diese Seite sammelt die Anfrage fuer einen Plattform-Reset.' + : 'Diese Seite sammelt die Anfrage fuer den Standort-Reset von ' . $tenantName . '.') ?> + Im Pilotbetrieb kann die Bestaetigung noch manuell begleitet werden. +
+Die Anwendung verbindet klassische Kaffeekasse, digitale Selbstbuchung und spaetere Geraeteintegration. Mandanten registrieren sich selbst, verwalten Mitglieder, Produkte, Einzahlungen und Papierlisten in einem PHP/MySQL-Stack, der auf Netcup-Webspace laeuft.
+ Pilotbetrieb fuer Teams und Vereine +Aktuell wird der Ablauf mit ausgewaehlten Teams verfeinert: Pilotmandanten koennen sich registrieren, Mitglieder fuer die Selbstbuchung anlegen und Backoffice-Prozesse fuer Papierlisten, Einzahlungen und spaetere RFID-Anbindung realistisch testen.