Initial Kaffeekasse SaaS restart
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\Response;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
use App\Services\AuditService;
|
||||
use App\Services\SetupService;
|
||||
use App\Services\TenantRegistrationService;
|
||||
use RuntimeException;
|
||||
|
||||
final class PlatformController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $config,
|
||||
private readonly Database $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
) {
|
||||
}
|
||||
|
||||
public function home(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
$stats = null;
|
||||
|
||||
if ($setup->isInstalled() && $this->database->isConfigured()) {
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$stats = [
|
||||
'tenants' => (int) $pdo->query('SELECT COUNT(*) FROM tenants')->fetchColumn(),
|
||||
'members' => (int) $pdo->query('SELECT COUNT(*) FROM members')->fetchColumn(),
|
||||
'events' => (int) $pdo->query('SELECT COUNT(*) FROM consumption_events')->fetchColumn(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
$stats = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->view->render('home/index', [
|
||||
'title' => 'Kaffeekasse fuer Teams, Vereine und Coworking',
|
||||
'setupComplete' => $setup->isInstalled(),
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
public function installForm(): void
|
||||
{
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
if ($setup->isInstalled()) {
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$this->view->render('install/index', [
|
||||
'title' => 'Installation',
|
||||
]);
|
||||
}
|
||||
|
||||
public function installSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$setup = new SetupService($this->rootPath);
|
||||
|
||||
try {
|
||||
$setup->install($_POST);
|
||||
$this->session->flash('success', 'Die Installation wurde abgeschlossen. Jetzt kannst du dich als Plattform-Admin anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/install');
|
||||
}
|
||||
}
|
||||
|
||||
public function registerForm(): void
|
||||
{
|
||||
if (!(new SetupService($this->rootPath))->isInstalled()) {
|
||||
Response::redirect('/install');
|
||||
}
|
||||
|
||||
$this->view->render('home/register', [
|
||||
'title' => 'Mandant registrieren',
|
||||
]);
|
||||
}
|
||||
|
||||
public function registerSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$audit = new AuditService($pdo);
|
||||
$service = new TenantRegistrationService($pdo, $audit);
|
||||
$slug = $service->register($_POST);
|
||||
$this->session->flash('success', 'Dein Mandant wurde angelegt. Du kannst dich jetzt anmelden.');
|
||||
Response::redirect('/t/' . rawurlencode($slug) . '/login');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/register');
|
||||
}
|
||||
}
|
||||
|
||||
public function adminLoginForm(): void
|
||||
{
|
||||
$this->view->render('admin/login', [
|
||||
'title' => 'Plattform-Login',
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLoginSubmit(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
try {
|
||||
$pdo = $this->database->pdo();
|
||||
$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'] ?? ''))),
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
throw new RuntimeException('Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||
}
|
||||
|
||||
$this->session->flash('success', 'Willkommen im Plattformbereich.');
|
||||
Response::redirect('/admin');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
}
|
||||
|
||||
public function adminDashboard(): void
|
||||
{
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
|
||||
if (!$auth->checkPlatformAdmin()) {
|
||||
$this->session->flash('error', 'Bitte zuerst als Plattform-Admin anmelden.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
$tenants = $pdo->query(
|
||||
'SELECT t.*,
|
||||
COUNT(DISTINCT m.id) AS member_count,
|
||||
COUNT(DISTINCT p.id) AS product_count
|
||||
FROM tenants t
|
||||
LEFT JOIN members m ON m.tenant_id = t.id
|
||||
LEFT JOIN products p ON p.tenant_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY t.created_at DESC'
|
||||
)->fetchAll();
|
||||
|
||||
$this->view->render('admin/index', [
|
||||
'title' => 'Plattform-Dashboard',
|
||||
'tenants' => $tenants,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminLogout(): void
|
||||
{
|
||||
$this->assertCsrf();
|
||||
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$auth->logout();
|
||||
$this->session->flash('success', 'Du wurdest aus dem Plattformbereich abgemeldet.');
|
||||
Response::redirect('/admin/login');
|
||||
}
|
||||
|
||||
private function assertCsrf(): void
|
||||
{
|
||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden. Bitte erneut versuchen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
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\LedgerService;
|
||||
use App\Services\RfidService;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $database,
|
||||
private readonly View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf
|
||||
) {
|
||||
}
|
||||
|
||||
public function loginForm(string $tenantSlug): void
|
||||
{
|
||||
$tenant = $this->tenant($tenantSlug);
|
||||
|
||||
$this->view->render('tenant/login', [
|
||||
'title' => 'Tenant-Login',
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
}
|
||||
|
||||
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'] ?? ''))),
|
||||
]);
|
||||
|
||||
if (!$success) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', 'Die Zugangsdaten konnten nicht verifiziert werden.');
|
||||
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
|
||||
{
|
||||
$this->assertCsrf();
|
||||
$pdo = $this->database->pdo();
|
||||
$auth = new Auth($pdo, $this->session);
|
||||
$auth->logout();
|
||||
$this->session->flash('success', 'Du wurdest abgemeldet.');
|
||||
Response::redirect(tenant_url($tenantSlug, 'login'));
|
||||
}
|
||||
|
||||
public function dashboard(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
$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']]);
|
||||
|
||||
$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']]);
|
||||
|
||||
$this->view->render('tenant/dashboard', [
|
||||
'title' => 'Dashboard',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'summary' => $summary,
|
||||
'latestEvents' => $eventsStatement->fetchAll(),
|
||||
'memberBalances' => $balancesStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
$userId = null;
|
||||
|
||||
if ($email !== '') {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||
}
|
||||
|
||||
if ($loginPassword === '' || mb_strlen($loginPassword) < 12) {
|
||||
throw new RuntimeException('Fuer Login-faehige Mitglieder ist ein Passwort mit mindestens 12 Zeichen noetig.');
|
||||
}
|
||||
|
||||
$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 !== '' ? $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 gespeichert.');
|
||||
} 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',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'products' => $productsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bookings(string $tenantSlug): void
|
||||
{
|
||||
[$tenant, $authState] = $this->tenantAccess($tenantSlug);
|
||||
$pdo = $this->database->pdo();
|
||||
|
||||
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.');
|
||||
} catch (\Throwable $throwable) {
|
||||
remember_old_input($_POST);
|
||||
$this->session->flash('error', $throwable->getMessage());
|
||||
}
|
||||
|
||||
Response::redirect(tenant_url($tenantSlug, 'bookings'));
|
||||
}
|
||||
|
||||
$members = $this->membersForTenant((int) $tenant['id']);
|
||||
$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']]);
|
||||
|
||||
$this->view->render('tenant/bookings', [
|
||||
'title' => 'Digitale Buchungen',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'products' => $products,
|
||||
'bookings' => $bookingsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
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));
|
||||
$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());
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
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' => 'Einzahlungen',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'payments' => $paymentsStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
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', 'Die Papierlisten-Position 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 ins Ledger uebernommen.');
|
||||
}
|
||||
} 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
|
||||
FROM paper_sheets ps
|
||||
LEFT JOIN paper_sheet_lines psl ON psl.paper_sheet_id = ps.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' => 'Papierlisten',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'products' => $products,
|
||||
'sheets' => $sheets,
|
||||
'sheetLines' => $linesStatement->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
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', 'Das RFID-Geraet wurde vorbereitet. Token: ' . $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 RFID-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' => 'RFID-Roadmap',
|
||||
'tenant' => $tenant,
|
||||
'authState' => $authState,
|
||||
'members' => $members,
|
||||
'devices' => $devices->fetchAll(),
|
||||
'tags' => $tags->fetchAll(),
|
||||
'events' => $events->fetchAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function rfidIngest(): void
|
||||
{
|
||||
$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('Der Mandant ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
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', 'Dafuer fehlen die passenden Rechte.');
|
||||
Response::redirect(tenant_url($tenantSlug));
|
||||
}
|
||||
|
||||
return [$tenant, $auth->user()];
|
||||
}
|
||||
|
||||
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 assertCsrf(): void
|
||||
{
|
||||
if (!request_origin_matches_host() || !$this->csrf->validate($_POST['_token'] ?? null)) {
|
||||
throw new RuntimeException('Die Anfrage konnte nicht bestaetigt werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly Session $session
|
||||
) {
|
||||
}
|
||||
|
||||
public function attemptPlatformLogin(string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
|
||||
);
|
||||
$statement->execute(['email' => mb_strtolower($email)]);
|
||||
$user = $statement->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->session->regenerate();
|
||||
$this->session->put('auth', [
|
||||
'user_id' => (int) $user['id'],
|
||||
'platform_role' => $user['platform_role'],
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT u.id,
|
||||
u.password_hash,
|
||||
m.tenant_id,
|
||||
m.role,
|
||||
t.slug AS tenant_slug
|
||||
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"
|
||||
WHERE u.email = :email
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'slug' => $tenantSlug,
|
||||
'email' => mb_strtolower($email),
|
||||
]);
|
||||
$membership = $statement->fetch();
|
||||
|
||||
if (!$membership || !password_verify($password, $membership['password_hash'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$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'],
|
||||
'platform_role' => 'user',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function user(): ?array
|
||||
{
|
||||
$auth = $this->session->get('auth');
|
||||
|
||||
return is_array($auth) ? $auth : null;
|
||||
}
|
||||
|
||||
public function checkPlatformAdmin(): bool
|
||||
{
|
||||
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
|
||||
}
|
||||
|
||||
public function checkTenantAccess(string $tenantSlug): bool
|
||||
{
|
||||
$auth = $this->user();
|
||||
|
||||
return is_array($auth)
|
||||
&& isset($auth['tenant_slug'])
|
||||
&& hash_equals($auth['tenant_slug'], $tenantSlug);
|
||||
}
|
||||
|
||||
public function requireRole(array $allowedRoles): bool
|
||||
{
|
||||
$role = $this->user()['tenant_role'] ?? null;
|
||||
|
||||
return is_string($role) && in_array($role, $allowedRoles, true);
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->session->invalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Autoloader
|
||||
{
|
||||
public static function register(string $rootPath): void
|
||||
{
|
||||
spl_autoload_register(static function (string $class) use ($rootPath): void {
|
||||
$prefix = 'App\\';
|
||||
|
||||
if (!str_starts_with($class, $prefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, strlen($prefix));
|
||||
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Csrf
|
||||
{
|
||||
public function __construct(private readonly Session $session)
|
||||
{
|
||||
}
|
||||
|
||||
public function token(): string
|
||||
{
|
||||
$token = $this->session->get('_csrf_token');
|
||||
|
||||
if (!is_string($token) || $token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$this->session->put('_csrf_token', $token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate(?string $token): bool
|
||||
{
|
||||
$expected = $this->session->get('_csrf_token');
|
||||
|
||||
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
final class Database
|
||||
{
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
public function __construct(private readonly array $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
if ($this->pdo instanceof PDO) {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$this->config['host'],
|
||||
$this->config['port'],
|
||||
$this->config['name'],
|
||||
$this->config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $exception) {
|
||||
throw new PDOException(
|
||||
'Database connection failed: ' . $exception->getMessage(),
|
||||
(int) $exception->getCode(),
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->config['name'] !== '' && $this->config['user'] !== '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Env
|
||||
{
|
||||
public static function load(string $file): void
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
|
||||
$name = trim($name);
|
||||
$value = trim($value);
|
||||
|
||||
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
|
||||
$value = trim($value, "\"'");
|
||||
}
|
||||
|
||||
$_ENV[$name] = $value;
|
||||
$_SERVER[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Response
|
||||
{
|
||||
public static function redirect(string $url): never
|
||||
{
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Seite nicht gefunden.'): void
|
||||
{
|
||||
http_response_code(404);
|
||||
echo $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class Session
|
||||
{
|
||||
public function start(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
'path' => '/',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_SESSION[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function put(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function forget(string $key): void
|
||||
{
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
public function flash(string $key, mixed $value): void
|
||||
{
|
||||
$_SESSION['_flash'][$key] = $value;
|
||||
}
|
||||
|
||||
public function pullFlash(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = $_SESSION['_flash'][$key] ?? $default;
|
||||
unset($_SESSION['_flash'][$key]);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function regenerate(): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public function invalidate(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function bySlug(string $slug): ?array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
|
||||
);
|
||||
$statement->execute(['slug' => $slug]);
|
||||
$tenant = $statement->fetch();
|
||||
|
||||
return $tenant ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
final class View
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $rootPath,
|
||||
private readonly array $shared = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function render(string $template, array $data = []): void
|
||||
{
|
||||
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
|
||||
|
||||
if (!is_file($templateFile)) {
|
||||
http_response_code(500);
|
||||
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
return;
|
||||
}
|
||||
|
||||
$shared = $this->shared;
|
||||
|
||||
extract($shared, EXTR_SKIP);
|
||||
extract($data, EXTR_SKIP);
|
||||
|
||||
require $templateFile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function log(
|
||||
?int $tenantId,
|
||||
?int $actorUserId,
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $result,
|
||||
array $meta = []
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO audit_events (
|
||||
tenant_id,
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
result,
|
||||
request_id,
|
||||
ip_hash,
|
||||
user_agent_hash,
|
||||
meta_json
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:actor_user_id,
|
||||
:action,
|
||||
:target_type,
|
||||
:target_id,
|
||||
:result,
|
||||
:request_id,
|
||||
:ip_hash,
|
||||
:user_agent_hash,
|
||||
:meta_json
|
||||
)'
|
||||
);
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
|
||||
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'actor_user_id' => $actorUserId,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'result' => $result,
|
||||
'request_id' => substr($requestId, 0, 36),
|
||||
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createConsumption(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
string $sourceCode,
|
||||
string $effectiveAt,
|
||||
int $recordedByUserId,
|
||||
float $quantity,
|
||||
?string $notes = null,
|
||||
?int $paperSheetLineId = null,
|
||||
?string $sourceReference = null
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, $sourceCode);
|
||||
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||
$totalCents = (int) round($priceCents * $quantity);
|
||||
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
|
||||
$recordedAt = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$eventInsert = $this->pdo->prepare(
|
||||
'INSERT INTO consumption_events (
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
source_id,
|
||||
paper_sheet_line_id,
|
||||
quantity,
|
||||
unit_price_cents,
|
||||
total_cents,
|
||||
effective_at,
|
||||
recorded_at,
|
||||
recorded_by_user_id,
|
||||
source_reference,
|
||||
notes
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:source_id,
|
||||
:paper_sheet_line_id,
|
||||
:quantity,
|
||||
:unit_price_cents,
|
||||
:total_cents,
|
||||
:effective_at,
|
||||
:recorded_at,
|
||||
:recorded_by_user_id,
|
||||
:source_reference,
|
||||
:notes
|
||||
)'
|
||||
);
|
||||
$eventInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'source_id' => $source['id'],
|
||||
'paper_sheet_line_id' => $paperSheetLineId,
|
||||
'quantity' => $quantity,
|
||||
'unit_price_cents' => $priceCents,
|
||||
'total_cents' => $totalCents,
|
||||
'effective_at' => $effectiveAt,
|
||||
'recorded_at' => $recordedAt,
|
||||
'recorded_by_user_id' => $recordedByUserId,
|
||||
'source_reference' => $sourceReference,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
$eventId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$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
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"consumption",
|
||||
"consumption_event",
|
||||
:reference_id,
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'reference_id' => $eventId,
|
||||
'description' => $description,
|
||||
'occurred_at' => $effectiveAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
$entryId = (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
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => $totalCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'coffee_revenue',
|
||||
'amount_cents' => -$totalCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'source' => $sourceCode,
|
||||
'total_cents' => $totalCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPayment(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $amountCents,
|
||||
string $occurredAt,
|
||||
int $recordedByUserId,
|
||||
?string $notes = null
|
||||
): void {
|
||||
if ($amountCents <= 0) {
|
||||
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id,
|
||||
reference_type
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"payment",
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id,
|
||||
"payment"
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
|
||||
$entryId = (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
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'cashbox',
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => -$amountCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPaperSheet(
|
||||
int $tenantId,
|
||||
string $title,
|
||||
?string $periodLabel,
|
||||
?string $periodStart,
|
||||
?string $periodEnd,
|
||||
?string $notes,
|
||||
int $createdByUserId
|
||||
): int {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheets (
|
||||
tenant_id,
|
||||
title,
|
||||
period_label,
|
||||
period_start,
|
||||
period_end,
|
||||
notes,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:title,
|
||||
:period_label,
|
||||
:period_start,
|
||||
:period_end,
|
||||
:notes,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $title,
|
||||
'period_label' => $periodLabel,
|
||||
'period_start' => $periodStart ?: null,
|
||||
'period_end' => $periodEnd ?: null,
|
||||
'notes' => $notes,
|
||||
'created_by_user_id' => $createdByUserId,
|
||||
]);
|
||||
|
||||
$sheetId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
|
||||
|
||||
return $sheetId;
|
||||
}
|
||||
|
||||
public function addPaperSheetLine(
|
||||
int $tenantId,
|
||||
int $sheetId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
float $quantity,
|
||||
?string $effectiveAt,
|
||||
?string $note,
|
||||
int $actorUserId
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$sheet = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheet->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$paperSheet = $sheet->fetch();
|
||||
|
||||
if (!$paperSheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($paperSheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheet_lines (
|
||||
paper_sheet_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
quantity,
|
||||
effective_at,
|
||||
note
|
||||
) VALUES (
|
||||
:paper_sheet_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:quantity,
|
||||
:effective_at,
|
||||
:note
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'effective_at' => $effectiveAt ?: null,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$lineId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
|
||||
'paper_sheet_id' => $sheetId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||
{
|
||||
$sheetStatement = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheetStatement->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$sheet = $sheetStatement->fetch();
|
||||
|
||||
if (!$sheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($sheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Die Papierliste wurde bereits verbucht.');
|
||||
}
|
||||
|
||||
$linesStatement = $this->pdo->prepare(
|
||||
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
|
||||
);
|
||||
$linesStatement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$lines = $linesStatement->fetchAll();
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.');
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$this->createConsumption(
|
||||
$tenantId,
|
||||
(int) $line['member_id'],
|
||||
(int) $line['product_id'],
|
||||
'paper_sheet',
|
||||
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
|
||||
$actorUserId,
|
||||
(float) $line['quantity'],
|
||||
$line['note'] ?: 'Papierliste',
|
||||
(int) $line['id'],
|
||||
'sheet:' . $sheetId
|
||||
);
|
||||
}
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$update->execute([
|
||||
'posted_at' => gmdate('Y-m-d H:i:s'),
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||
}
|
||||
|
||||
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT price_cents
|
||||
FROM product_prices
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND product_id = :product_id
|
||||
AND valid_from <= :effective_at
|
||||
AND (valid_until IS NULL OR valid_until > :effective_at)
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'effective_at' => $effectiveAt,
|
||||
]);
|
||||
$price = $statement->fetchColumn();
|
||||
|
||||
if ($price === false) {
|
||||
throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.');
|
||||
}
|
||||
|
||||
return (int) $price;
|
||||
}
|
||||
|
||||
private function findSource(int $tenantId, string $code): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
]);
|
||||
$source = $statement->fetch();
|
||||
|
||||
if (!$source) {
|
||||
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class RfidService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
|
||||
{
|
||||
if (trim($name) === '') {
|
||||
throw new RuntimeException('Bitte einen Geraetenamen angeben.');
|
||||
}
|
||||
|
||||
$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")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => trim($name),
|
||||
'location_label' => $location,
|
||||
'device_token' => $token,
|
||||
]);
|
||||
$deviceId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
|
||||
{
|
||||
if (trim($uid) === '') {
|
||||
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$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")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'uid_hash' => hash('sha256', trim($uid)),
|
||||
'label' => $label,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
|
||||
}
|
||||
|
||||
public function ingest(array $payload): void
|
||||
{
|
||||
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
|
||||
$uid = trim((string) ($payload['uid'] ?? ''));
|
||||
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
|
||||
if ($deviceToken === '' || $uid === '') {
|
||||
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
|
||||
}
|
||||
|
||||
$deviceStatement = $this->pdo->prepare(
|
||||
'SELECT d.id, d.tenant_id, d.status
|
||||
FROM rfid_devices d
|
||||
WHERE d.device_token = :device_token
|
||||
LIMIT 1'
|
||||
);
|
||||
$deviceStatement->execute(['device_token' => $deviceToken]);
|
||||
$device = $deviceStatement->fetch();
|
||||
|
||||
if (!$device) {
|
||||
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
||||
}
|
||||
|
||||
$status = 'received';
|
||||
$tagStatement = $this->pdo->prepare(
|
||||
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||
);
|
||||
$tagStatement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
]);
|
||||
|
||||
if ($tagStatement->fetch()) {
|
||||
$status = 'linked';
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_events (
|
||||
tenant_id,
|
||||
device_id,
|
||||
external_event_id,
|
||||
uid_hash,
|
||||
payload_json,
|
||||
status,
|
||||
received_at
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:device_id,
|
||||
:external_event_id,
|
||||
:uid_hash,
|
||||
:payload_json,
|
||||
:status,
|
||||
:received_at
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'device_id' => $device['id'],
|
||||
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'status' => $status,
|
||||
'received_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
|
||||
'status' => $status,
|
||||
'device_id' => (int) $device['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class SetupService
|
||||
{
|
||||
public function __construct(private readonly string $rootPath)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
return is_file($this->rootPath . '/storage/installed.lock');
|
||||
}
|
||||
|
||||
public function install(array $input): void
|
||||
{
|
||||
$required = [
|
||||
'app_url',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_name',
|
||||
'db_user',
|
||||
'admin_name',
|
||||
'admin_email',
|
||||
'admin_password',
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($input[$field] ?? '')) === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($input['admin_password']) < 12) {
|
||||
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'host' => trim((string) $input['db_host']),
|
||||
'port' => (int) $input['db_port'],
|
||||
'name' => trim((string) $input['db_name']),
|
||||
'user' => trim((string) $input['db_user']),
|
||||
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
|
||||
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||
$existing = $statement->fetchColumn();
|
||||
|
||||
if (!$existing) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "platform_admin")'
|
||||
);
|
||||
$insert->execute([
|
||||
'full_name' => trim((string) $input['admin_name']),
|
||||
'email' => mb_strtolower((string) $input['admin_email']),
|
||||
'password_hash' => secure_password_hash((string) $input['admin_password']),
|
||||
]);
|
||||
}
|
||||
|
||||
$envContent = $this->buildEnvFile($input);
|
||||
|
||||
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
$lockContent = json_encode([
|
||||
'installed_at' => gmdate(DATE_ATOM),
|
||||
'app_url' => trim((string) $input['app_url']),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
|
||||
}
|
||||
|
||||
private function buildEnvFile(array $input): string
|
||||
{
|
||||
$appKey = bin2hex(random_bytes(32));
|
||||
$rfidSecret = bin2hex(random_bytes(24));
|
||||
|
||||
$lines = [
|
||||
'APP_NAME="Kaffeekasse SaaS"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=0',
|
||||
'APP_URL=' . trim((string) $input['app_url']),
|
||||
'APP_TIMEZONE=Europe/Berlin',
|
||||
'APP_KEY=' . $appKey,
|
||||
'',
|
||||
'DB_HOST=' . trim((string) $input['db_host']),
|
||||
'DB_PORT=' . (int) $input['db_port'],
|
||||
'DB_NAME=' . trim((string) $input['db_name']),
|
||||
'DB_USER=' . trim((string) $input['db_user']),
|
||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||
'',
|
||||
'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
|
||||
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||
];
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(array $input): string
|
||||
{
|
||||
$name = trim((string) ($input['tenant_name'] ?? ''));
|
||||
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
|
||||
$ownerName = trim((string) ($input['owner_name'] ?? ''));
|
||||
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
|
||||
$ownerPassword = (string) ($input['owner_password'] ?? '');
|
||||
$plan = (string) ($input['plan'] ?? 'starter');
|
||||
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
|
||||
|
||||
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
|
||||
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ownerPassword) < 12) {
|
||||
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$allowedPlans = ['starter', 'team', 'business'];
|
||||
|
||||
if (!in_array($plan, $allowedPlans, true)) {
|
||||
$plan = 'starter';
|
||||
}
|
||||
|
||||
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
|
||||
$existingTenant->execute(['slug' => $slug]);
|
||||
|
||||
if ($existingTenant->fetchColumn()) {
|
||||
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
|
||||
}
|
||||
|
||||
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$existingUser->execute(['email' => $ownerEmail]);
|
||||
|
||||
if ($existingUser->fetchColumn()) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.');
|
||||
}
|
||||
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$userInsert = $this->pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "user")'
|
||||
);
|
||||
$userInsert->execute([
|
||||
'full_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
'password_hash' => secure_password_hash($ownerPassword),
|
||||
]);
|
||||
$userId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$tenantInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||
VALUES (:name, :slug, :plan, "active", :created_at)'
|
||||
);
|
||||
$tenantInsert->execute([
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'plan' => $plan,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$tenantId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$membershipInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, "owner", "active")'
|
||||
);
|
||||
$membershipInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$settingsInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_settings (
|
||||
tenant_id,
|
||||
currency_code,
|
||||
default_product_price_cents,
|
||||
allow_self_service,
|
||||
allow_paper_lists,
|
||||
allow_rfid
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
"EUR",
|
||||
:default_product_price_cents,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
)'
|
||||
);
|
||||
$settingsInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'default_product_price_cents' => $defaultPriceCents,
|
||||
]);
|
||||
|
||||
$memberInsert = $this->pdo->prepare(
|
||||
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
|
||||
);
|
||||
$memberInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'display_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
]);
|
||||
|
||||
$sourceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||
VALUES (:tenant_id, :code, :name, :channel, 1)'
|
||||
);
|
||||
|
||||
foreach ([
|
||||
['digital_self', 'Digitale Selbstbuchung', 'digital'],
|
||||
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
|
||||
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
|
||||
['rfid_reader', 'RFID-Geraet', 'rfid'],
|
||||
] as [$code, $sourceName, $channel]) {
|
||||
$sourceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $sourceName,
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['Kaffee', $defaultPriceCents],
|
||||
['Espresso', $defaultPriceCents],
|
||||
['Tee', max(80, $defaultPriceCents - 20)],
|
||||
] as [$productName, $priceCents]) {
|
||||
$productInsert = $this->pdo->prepare(
|
||||
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)'
|
||||
);
|
||||
$productInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $productName,
|
||||
'sku' => strtolower(str_replace(' ', '-', $productName)),
|
||||
]);
|
||||
$productId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$priceInsert = $this->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' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'price_cents' => $priceCents,
|
||||
'valid_from' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
|
||||
'plan' => $plan,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
|
||||
$value = trim($value, '-');
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Session;
|
||||
|
||||
function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function current_path(): string
|
||||
{
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
|
||||
return is_string($uri) ? $uri : '/';
|
||||
}
|
||||
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
return '/assets/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function old(string $key, mixed $default = ''): mixed
|
||||
{
|
||||
return $_SESSION['_old'][$key] ?? $default;
|
||||
}
|
||||
|
||||
function remember_old_input(array $input): void
|
||||
{
|
||||
foreach (['password', 'admin_password', 'login_password', 'owner_password', 'db_pass'] as $sensitiveKey) {
|
||||
if (array_key_exists($sensitiveKey, $input)) {
|
||||
unset($input[$sensitiveKey]);
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['_old'] = $input;
|
||||
}
|
||||
|
||||
function clear_old_input(): void
|
||||
{
|
||||
unset($_SESSION['_old']);
|
||||
}
|
||||
|
||||
function csrf_field(Csrf $csrf): string
|
||||
{
|
||||
return '<input type="hidden" name="_token" value="' . e($csrf->token()) . '">';
|
||||
}
|
||||
|
||||
function flash(Session $session, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $session->pullFlash($key, $default);
|
||||
}
|
||||
|
||||
function tenant_url(string $tenantSlug, string $path = ''): string
|
||||
{
|
||||
$suffix = $path === '' ? '' : '/' . ltrim($path, '/');
|
||||
|
||||
return '/t/' . rawurlencode($tenantSlug) . $suffix;
|
||||
}
|
||||
|
||||
function money_from_cents(int $amountCents): string
|
||||
{
|
||||
return number_format($amountCents / 100, 2, ',', '.') . ' EUR';
|
||||
}
|
||||
|
||||
function secure_password_hash(string $password): string
|
||||
{
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||
|
||||
return password_hash($password, $algo);
|
||||
}
|
||||
|
||||
function normalize_datetime_input(?string $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
|
||||
if ($value === '') {
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$normalized = str_replace('T', ' ', $value);
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized)) {
|
||||
return $normalized . ':00';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function request_origin_matches_host(): bool
|
||||
{
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
|
||||
if ($host === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['HTTP_ORIGIN', 'HTTP_REFERER'] as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$originHost = parse_url((string) $_SERVER[$header], PHP_URL_HOST);
|
||||
|
||||
if (is_string($originHost) && $originHost !== '' && !hash_equals($host, $originHost)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Core\Autoloader;
|
||||
use App\Core\Csrf;
|
||||
use App\Core\Database;
|
||||
use App\Core\Env;
|
||||
use App\Core\Session;
|
||||
use App\Core\View;
|
||||
|
||||
$rootPath = dirname(__DIR__);
|
||||
|
||||
require_once $rootPath . '/app/Support/helpers.php';
|
||||
require_once $rootPath . '/app/Core/Autoloader.php';
|
||||
|
||||
Autoloader::register($rootPath);
|
||||
Env::load($rootPath . '/.env');
|
||||
|
||||
$config = require $rootPath . '/config/app.php';
|
||||
|
||||
date_default_timezone_set($config['timezone']);
|
||||
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
ini_set('session.use_trans_sid', '0');
|
||||
session_name('kaffeekasse_session');
|
||||
|
||||
$session = new Session();
|
||||
$session->start();
|
||||
|
||||
$csrf = new Csrf($session);
|
||||
$database = new Database($config['db']);
|
||||
$view = new View($rootPath, [
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
]);
|
||||
|
||||
return [
|
||||
'rootPath' => $rootPath,
|
||||
'config' => $config,
|
||||
'session' => $session,
|
||||
'csrf' => $csrf,
|
||||
'database' => $database,
|
||||
'view' => $view,
|
||||
];
|
||||
Reference in New Issue
Block a user