Phase1 Bearbeitung

This commit is contained in:
2026-06-15 18:36:57 +02:00
parent e4fa714419
commit 06645f1e9c
29 changed files with 3367 additions and 300 deletions
+270 -1
View File
@@ -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';
}
}
+533 -96
View File
@@ -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);
}
}
}