Initial Kaffeekasse SaaS restart

This commit is contained in:
2026-06-15 17:13:38 +02:00
commit b08eb93547
54 changed files with 4617 additions and 0 deletions
+65
View File
@@ -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,
]);
}
}
+478
View File
@@ -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;
}
}
+130
View File
@@ -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'],
]);
}
}
+132
View File
@@ -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;
}
}
+197
View File
@@ -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;
}
}