1078 lines
43 KiB
PHP
1078 lines
43 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\Auth;
|
|
use App\Core\Csrf;
|
|
use App\Core\Database;
|
|
use App\Core\DatabaseInterface;
|
|
use App\Core\Response;
|
|
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;
|
|
|
|
final class TenantController
|
|
{
|
|
public function __construct(
|
|
private readonly DatabaseInterface $database,
|
|
private readonly View $view,
|
|
private readonly Session $session,
|
|
private readonly Csrf $csrf,
|
|
private readonly array $config = []
|
|
) {
|
|
}
|
|
|
|
public function loginForm(string $tenantSlug): void
|
|
{
|
|
$tenant = $this->tenant($tenantSlug);
|
|
|
|
$this->view->render('tenant/login', [
|
|
'title' => 'Kaffeelisten-Login',
|
|
'tenant' => $tenant,
|
|
]);
|
|
}
|
|
|
|
public function loginSubmit(string $tenantSlug): void
|
|
{
|
|
$this->assertCsrf();
|
|
|
|
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);
|
|
|
|
$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 Buchung ist für diese Kaffeeliste derzeit deaktiviert.');
|
|
}
|
|
|
|
$this->clearRateLimitScopes($rateLimiter, $scopes);
|
|
$this->session->flash('success', 'Willkommen in Ihrer Kaffeeliste.');
|
|
Response::redirect(tenant_url($tenantSlug));
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
|
}
|
|
}
|
|
|
|
public function logout(string $tenantSlug): void
|
|
{
|
|
$this->assertCsrf();
|
|
$pdo = $this->database->pdo();
|
|
$auth = new Auth($pdo, $this->session);
|
|
$auth->logout();
|
|
$this->session->flash('success', 'Sie wurden abgemeldet.');
|
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
|
}
|
|
|
|
public function dashboard(string $tenantSlug): void
|
|
{
|
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
|
$pdo = $this->database->pdo();
|
|
$isManager = $this->isTenantManager($authState);
|
|
$currentMember = $this->memberRecordForAuthState((int) $tenant['id'], $authState);
|
|
|
|
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,
|
|
s.code AS source_code,
|
|
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']]);
|
|
} else {
|
|
if ($currentMember === null) {
|
|
throw new RuntimeException('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste 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,
|
|
s.code AS source_code,
|
|
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' => 'Kaffeekasse',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'summary' => $summary,
|
|
'latestEvents' => $eventsStatement->fetchAll(),
|
|
'memberBalances' => $balancesStatement->fetchAll(),
|
|
'isManager' => $isManager,
|
|
'currentMember' => $currentMember,
|
|
]);
|
|
}
|
|
|
|
public function members(string $tenantSlug): void
|
|
{
|
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
|
$pdo = $this->database->pdo();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$this->assertCsrf();
|
|
|
|
try {
|
|
$displayName = trim((string) ($_POST['display_name'] ?? ''));
|
|
$email = trim((string) ($_POST['email'] ?? ''));
|
|
$accessPin = trim((string) ($_POST['access_pin'] ?? ''));
|
|
$loginPassword = (string) ($_POST['login_password'] ?? '');
|
|
|
|
if ($displayName === '') {
|
|
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 !== '') {
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
throw new RuntimeException('Die E-Mail-Adresse ist ungültig.');
|
|
}
|
|
|
|
if ($loginPassword === '' || mb_strlen($loginPassword) < 12) {
|
|
throw new RuntimeException('Für Mitglieder mit Login ist ein Passwort mit mindestens 12 Zeichen nötig.');
|
|
}
|
|
|
|
$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")'
|
|
);
|
|
$userInsert->execute([
|
|
'full_name' => $displayName,
|
|
'email' => mb_strtolower($email),
|
|
'password_hash' => secure_password_hash($loginPassword),
|
|
]);
|
|
$userId = (int) $pdo->lastInsertId();
|
|
|
|
$membershipInsert = $pdo->prepare(
|
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
|
VALUES (:tenant_id, :user_id, "member", "active")'
|
|
);
|
|
$membershipInsert->execute([
|
|
'tenant_id' => $tenant['id'],
|
|
'user_id' => $userId,
|
|
]);
|
|
}
|
|
|
|
$memberInsert = $pdo->prepare(
|
|
'INSERT INTO members (tenant_id, user_id, display_name, email, access_pin, status)
|
|
VALUES (:tenant_id, :user_id, :display_name, :email, :access_pin, "active")'
|
|
);
|
|
$memberInsert->execute([
|
|
'tenant_id' => $tenant['id'],
|
|
'user_id' => $userId,
|
|
'display_name' => $displayName,
|
|
'email' => $email !== '' ? mb_strtolower($email) : 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');
|
|
$this->session->flash('success', 'Das Mitglied wurde angelegt.');
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'members'));
|
|
}
|
|
|
|
$membersStatement = $pdo->prepare(
|
|
'SELECT m.*, u.email AS login_email
|
|
FROM members m
|
|
LEFT JOIN users u ON u.id = m.user_id
|
|
WHERE m.tenant_id = :tenant_id
|
|
ORDER BY m.display_name ASC'
|
|
);
|
|
$membersStatement->execute(['tenant_id' => $tenant['id']]);
|
|
|
|
$this->view->render('tenant/members', [
|
|
'title' => 'Mitglieder',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'members' => $membersStatement->fetchAll(),
|
|
]);
|
|
}
|
|
|
|
public function products(string $tenantSlug): void
|
|
{
|
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
|
$pdo = $this->database->pdo();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$this->assertCsrf();
|
|
|
|
try {
|
|
$name = trim((string) ($_POST['name'] ?? ''));
|
|
$priceEur = (float) ($_POST['price_eur'] ?? 0);
|
|
|
|
if ($name === '' || $priceEur <= 0) {
|
|
throw new RuntimeException('Bitte Produktname und Preis angeben.');
|
|
}
|
|
|
|
$productInsert = $pdo->prepare(
|
|
'INSERT INTO products (tenant_id, name, sku, is_active)
|
|
VALUES (:tenant_id, :name, :sku, 1)'
|
|
);
|
|
$productInsert->execute([
|
|
'tenant_id' => $tenant['id'],
|
|
'name' => $name,
|
|
'sku' => strtolower(str_replace(' ', '-', $name)),
|
|
]);
|
|
$productId = (int) $pdo->lastInsertId();
|
|
|
|
$priceInsert = $pdo->prepare(
|
|
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
|
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
|
);
|
|
$priceInsert->execute([
|
|
'tenant_id' => $tenant['id'],
|
|
'product_id' => $productId,
|
|
'price_cents' => (int) round($priceEur * 100),
|
|
'valid_from' => gmdate('Y-m-d H:i:s'),
|
|
]);
|
|
|
|
(new AuditService($pdo))->log((int) $tenant['id'], (int) $authState['user_id'], 'product.created', 'product', $productId, 'success');
|
|
$this->session->flash('success', 'Das Produkt wurde angelegt.');
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'products'));
|
|
}
|
|
|
|
$productsStatement = $pdo->prepare(
|
|
'SELECT p.*, pp.price_cents
|
|
FROM products p
|
|
LEFT JOIN product_prices pp
|
|
ON pp.product_id = p.id
|
|
AND pp.tenant_id = p.tenant_id
|
|
AND pp.valid_until IS NULL
|
|
WHERE p.tenant_id = :tenant_id
|
|
ORDER BY p.name ASC'
|
|
);
|
|
$productsStatement->execute(['tenant_id' => $tenant['id']]);
|
|
|
|
$this->view->render('tenant/products', [
|
|
'title' => 'Produkte und Preise',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'products' => $productsStatement->fetchAll(),
|
|
]);
|
|
}
|
|
|
|
public function bookings(string $tenantSlug): void
|
|
{
|
|
[$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));
|
|
$action = (string) ($_POST['action'] ?? 'create_booking');
|
|
|
|
if ($action === 'reverse_booking') {
|
|
if (!$isManager) {
|
|
throw new RuntimeException('Nur Administratorinnen und Administratoren dürfen 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 Kaffeebuchung wurde gespeichert.');
|
|
}
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
|
}
|
|
|
|
$members = $isManager
|
|
? $this->membersForTenant((int) $tenant['id'])
|
|
: ($currentMember !== null ? [['id' => $currentMember['id'], 'display_name' => $currentMember['display_name']]] : []);
|
|
$products = $this->productsForTenant((int) $tenant['id']);
|
|
|
|
$sql = 'SELECT c.*,
|
|
m.display_name AS member_name,
|
|
p.name AS product_name,
|
|
s.name AS source_name,
|
|
s.code AS source_code,
|
|
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('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste 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' => 'Kaffee buchen',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'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('Für diesen Benutzer ist kein aktives Mitglied in der Kaffeeliste 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']);
|
|
$pdo = $this->database->pdo();
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$this->assertCsrf();
|
|
|
|
try {
|
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
|
$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 gutgeschrieben.');
|
|
}
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'payments'));
|
|
}
|
|
|
|
$members = $this->membersForTenant((int) $tenant['id']);
|
|
$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,
|
|
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
|
|
ORDER BY le.occurred_at DESC
|
|
LIMIT 30'
|
|
);
|
|
$paymentsStatement->execute(['tenant_id' => $tenant['id']]);
|
|
|
|
$this->view->render('tenant/payments', [
|
|
'title' => 'Geld einzahlen',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'members' => $members,
|
|
'payments' => $paymentsStatement->fetchAll(),
|
|
'isManager' => true,
|
|
]);
|
|
}
|
|
|
|
public function paperSheets(string $tenantSlug): void
|
|
{
|
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
|
$pdo = $this->database->pdo();
|
|
$service = new LedgerService($pdo, new AuditService($pdo));
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$this->assertCsrf();
|
|
|
|
try {
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
|
|
if ($action === 'create_sheet') {
|
|
$service->createPaperSheet(
|
|
(int) $tenant['id'],
|
|
trim((string) ($_POST['title'] ?? '')),
|
|
trim((string) ($_POST['period_label'] ?? '')) ?: null,
|
|
trim((string) ($_POST['period_start'] ?? '')) ?: null,
|
|
trim((string) ($_POST['period_end'] ?? '')) ?: null,
|
|
trim((string) ($_POST['notes'] ?? '')) ?: null,
|
|
(int) $authState['user_id']
|
|
);
|
|
$this->session->flash('success', 'Die Papierliste wurde angelegt.');
|
|
} elseif ($action === 'add_line') {
|
|
$service->addPaperSheetLine(
|
|
(int) $tenant['id'],
|
|
(int) ($_POST['paper_sheet_id'] ?? 0),
|
|
(int) ($_POST['member_id'] ?? 0),
|
|
(int) ($_POST['product_id'] ?? 0),
|
|
(float) ($_POST['quantity'] ?? 1),
|
|
trim((string) ($_POST['effective_at'] ?? '')) !== '' ? normalize_datetime_input((string) $_POST['effective_at']) : null,
|
|
trim((string) ($_POST['note'] ?? '')) ?: null,
|
|
(int) $authState['user_id']
|
|
);
|
|
$this->session->flash('success', 'Der Eintrag aus der Papierliste wurde gespeichert.');
|
|
} 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 als Buchungen übernommen.');
|
|
} 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 übernommene Papierliste wurde storniert.');
|
|
}
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'paper-sheets'));
|
|
}
|
|
|
|
$members = $this->membersForTenant((int) $tenant['id']);
|
|
$products = $this->productsForTenant((int) $tenant['id']);
|
|
$sheetsStatement = $pdo->prepare(
|
|
'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'
|
|
);
|
|
$sheetsStatement->execute(['tenant_id' => $tenant['id']]);
|
|
$sheets = $sheetsStatement->fetchAll();
|
|
|
|
$linesStatement = $pdo->prepare(
|
|
'SELECT psl.*, m.display_name AS member_name, p.name AS product_name
|
|
FROM paper_sheet_lines psl
|
|
INNER JOIN members m ON m.id = psl.member_id
|
|
INNER JOIN products p ON p.id = psl.product_id
|
|
WHERE psl.tenant_id = :tenant_id
|
|
ORDER BY psl.created_at DESC'
|
|
);
|
|
$linesStatement->execute(['tenant_id' => $tenant['id']]);
|
|
|
|
$this->view->render('tenant/paper-sheets', [
|
|
'title' => 'Papierliste nachtragen',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'members' => $members,
|
|
'products' => $products,
|
|
'sheets' => $sheets,
|
|
'sheetLines' => $linesStatement->fetchAll(),
|
|
'isManager' => true,
|
|
]);
|
|
}
|
|
|
|
public function rfid(string $tenantSlug): void
|
|
{
|
|
[$tenant, $authState] = $this->tenantAccess($tenantSlug, ['owner', 'admin']);
|
|
$pdo = $this->database->pdo();
|
|
$service = new RfidService($pdo, new AuditService($pdo));
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
$this->assertCsrf();
|
|
|
|
try {
|
|
$action = (string) ($_POST['action'] ?? '');
|
|
|
|
if ($action === 'create_device') {
|
|
$token = $service->createDevice(
|
|
(int) $tenant['id'],
|
|
trim((string) ($_POST['name'] ?? '')),
|
|
trim((string) ($_POST['location_label'] ?? '')) ?: null,
|
|
(int) $authState['user_id']
|
|
);
|
|
$this->session->flash('success', 'Der Kartenleser wurde angelegt. Das Geräte-Token wird einmalig angezeigt: ' . $token);
|
|
} elseif ($action === 'assign_tag') {
|
|
$service->assignTag(
|
|
(int) $tenant['id'],
|
|
(int) ($_POST['member_id'] ?? 0),
|
|
trim((string) ($_POST['uid'] ?? '')),
|
|
trim((string) ($_POST['label'] ?? '')) ?: null,
|
|
(int) $authState['user_id']
|
|
);
|
|
$this->session->flash('success', 'Die Karte wurde zugewiesen.');
|
|
}
|
|
} catch (\Throwable $throwable) {
|
|
remember_old_input($_POST);
|
|
$this->session->flash('error', $throwable->getMessage());
|
|
}
|
|
|
|
Response::redirect(tenant_url($tenantSlug, 'rfid'));
|
|
}
|
|
|
|
$members = $this->membersForTenant((int) $tenant['id']);
|
|
$devices = $pdo->prepare('SELECT * FROM rfid_devices WHERE tenant_id = :tenant_id ORDER BY created_at DESC');
|
|
$devices->execute(['tenant_id' => $tenant['id']]);
|
|
$tags = $pdo->prepare(
|
|
'SELECT t.*, m.display_name AS member_name
|
|
FROM rfid_tags t
|
|
LEFT JOIN members m ON m.id = t.member_id
|
|
WHERE t.tenant_id = :tenant_id
|
|
ORDER BY t.created_at DESC'
|
|
);
|
|
$tags->execute(['tenant_id' => $tenant['id']]);
|
|
$events = $pdo->prepare(
|
|
'SELECT e.*, d.name AS device_name
|
|
FROM rfid_events e
|
|
LEFT JOIN rfid_devices d ON d.id = e.device_id
|
|
WHERE e.tenant_id = :tenant_id
|
|
ORDER BY e.received_at DESC
|
|
LIMIT 20'
|
|
);
|
|
$events->execute(['tenant_id' => $tenant['id']]);
|
|
|
|
$this->view->render('tenant/rfid', [
|
|
'title' => 'Kartenleser einrichten',
|
|
'tenant' => $tenant,
|
|
'authState' => $authState,
|
|
'members' => $members,
|
|
'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)) {
|
|
http_response_code(422);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => 'Invalid payload']);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$pdo = $this->database->pdo();
|
|
(new RfidService($pdo, new AuditService($pdo)))->ingest($payload);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['status' => 'accepted']);
|
|
} catch (\Throwable $throwable) {
|
|
http_response_code(422);
|
|
header('Content-Type: application/json');
|
|
echo json_encode(['error' => $throwable->getMessage()]);
|
|
}
|
|
}
|
|
|
|
private function tenant(string $tenantSlug): array
|
|
{
|
|
$pdo = $this->database->pdo();
|
|
$tenant = (new TenantContext($pdo))->bySlug($tenantSlug);
|
|
|
|
if (!$tenant || $tenant['status'] === 'suspended') {
|
|
throw new RuntimeException('Die Kaffeeliste ist nicht verfügbar.');
|
|
}
|
|
|
|
return $tenant;
|
|
}
|
|
|
|
private function tenantAccess(string $tenantSlug, array $roles = []): array
|
|
{
|
|
$tenant = $this->tenant($tenantSlug);
|
|
$pdo = $this->database->pdo();
|
|
$auth = new Auth($pdo, $this->session);
|
|
|
|
if (!$auth->checkTenantAccess($tenantSlug)) {
|
|
$this->session->flash('error', 'Bitte zuerst anmelden.');
|
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
|
}
|
|
|
|
if ($roles !== [] && !$auth->requireRole($roles)) {
|
|
$this->session->flash('error', 'Dafür fehlen die passenden Rechte.');
|
|
Response::redirect(tenant_url($tenantSlug));
|
|
}
|
|
|
|
$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 Buchung ist für diese Kaffeeliste derzeit deaktiviert.');
|
|
Response::redirect(tenant_url($tenantSlug, 'login'));
|
|
}
|
|
|
|
return [$tenant, $authState];
|
|
}
|
|
|
|
private function membersForTenant(int $tenantId): array
|
|
{
|
|
$statement = $this->database->pdo()->prepare(
|
|
'SELECT id, display_name FROM members WHERE tenant_id = :tenant_id AND status = "active" ORDER BY display_name ASC'
|
|
);
|
|
$statement->execute(['tenant_id' => $tenantId]);
|
|
|
|
return $statement->fetchAll();
|
|
}
|
|
|
|
private function productsForTenant(int $tenantId): array
|
|
{
|
|
$statement = $this->database->pdo()->prepare(
|
|
'SELECT p.id, p.name, pp.price_cents
|
|
FROM products p
|
|
LEFT JOIN product_prices pp
|
|
ON pp.product_id = p.id
|
|
AND pp.tenant_id = p.tenant_id
|
|
AND pp.valid_until IS NULL
|
|
WHERE p.tenant_id = :tenant_id
|
|
AND p.is_active = 1
|
|
ORDER BY p.name ASC'
|
|
);
|
|
$statement->execute(['tenant_id' => $tenantId]);
|
|
|
|
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 für die Buchung auswählen.');
|
|
}
|
|
|
|
return $this->assertMemberIdBelongsToTenant($tenantId, $postedMemberId);
|
|
}
|
|
|
|
if ($currentMember === null) {
|
|
throw new RuntimeException('Für dieses Konto ist kein buchungsfähiges Mitglied hinterlegt.');
|
|
}
|
|
|
|
if ($postedMemberId > 0 && $postedMemberId !== (int) $currentMember['id']) {
|
|
throw new RuntimeException('Sie dürfen nur für Ihr 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 gehört nicht zu dieser Kaffeeliste.');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|