assertTenantMembership($tenantId, $recordedByUserId); $this->assertTenantMember($tenantId, $memberId); $this->assertTenantProduct($tenantId, $productId); $source = $this->findSource($tenantId, $sourceCode); $priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt); $totalCents = (int) round($priceCents * $quantity); $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.'); } $this->assertTenantMembership($tenantId, $recordedByUserId); $this->assertTenantMember($tenantId, $memberId); $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 { $this->assertTenantMembership($tenantId, $createdByUserId); if (trim($title) === '') { throw new RuntimeException('Bitte einen Titel fuer die Papierliste angeben.'); } $statement = $this->pdo->prepare( 'INSERT INTO paper_sheets ( tenant_id, 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.'); } $this->assertTenantMembership($tenantId, $actorUserId); $this->assertTenantMember($tenantId, $memberId); $this->assertTenantProduct($tenantId, $productId); $sheet = $this->pdo->prepare( 'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' ); $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 { $this->assertTenantMembership($tenantId, $actorUserId); $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'); } public function reverseLedgerEntry(int $tenantId, int $ledgerEntryId, int $actorUserId, ?string $reason = null): int { $this->assertTenantMembership($tenantId, $actorUserId); $entry = $this->ledgerEntry($tenantId, $ledgerEntryId); if ($this->hasReversal($tenantId, $ledgerEntryId)) { throw new RuntimeException('Dieser Vorgang wurde bereits storniert.'); } $linesStatement = $this->pdo->prepare( 'SELECT member_id, account_code, amount_cents FROM ledger_lines WHERE tenant_id = :tenant_id AND ledger_entry_id = :ledger_entry_id ORDER BY id ASC' ); $linesStatement->execute([ 'tenant_id' => $tenantId, 'ledger_entry_id' => $ledgerEntryId, ]); $lines = $linesStatement->fetchAll(); if ($lines === []) { throw new RuntimeException('Es wurden keine Ledger-Zeilen fuer den Vorgang gefunden.'); } $description = 'Storno: ' . $entry['description']; if ($reason !== null && trim($reason) !== '') { $description .= ' (' . trim($reason) . ')'; } $this->pdo->beginTransaction(); try { $entryInsert = $this->pdo->prepare( 'INSERT INTO ledger_entries ( tenant_id, member_id, source_id, type, reference_type, reference_id, description, occurred_at, created_by_user_id, reversal_of_entry_id ) VALUES ( :tenant_id, :member_id, :source_id, "correction", "ledger_reversal", :reference_id, :description, :occurred_at, :created_by_user_id, :reversal_of_entry_id )' ); $entryInsert->execute([ 'tenant_id' => $tenantId, 'member_id' => $entry['member_id'], 'source_id' => $entry['source_id'], 'reference_id' => $ledgerEntryId, 'description' => $description, 'occurred_at' => gmdate('Y-m-d H:i:s'), 'created_by_user_id' => $actorUserId, 'reversal_of_entry_id' => $ledgerEntryId, ]); $reversalId = (int) $this->pdo->lastInsertId(); $lineInsert = $this->pdo->prepare( 'INSERT INTO ledger_lines ( ledger_entry_id, tenant_id, member_id, account_code, amount_cents ) VALUES ( :ledger_entry_id, :tenant_id, :member_id, :account_code, :amount_cents )' ); foreach ($lines as $line) { $lineInsert->execute([ 'ledger_entry_id' => $reversalId, 'tenant_id' => $tenantId, 'member_id' => $line['member_id'] !== null ? (int) $line['member_id'] : null, 'account_code' => $line['account_code'], 'amount_cents' => 0 - (int) $line['amount_cents'], ]); } $this->pdo->commit(); } catch (\Throwable $throwable) { $this->pdo->rollBack(); throw $throwable; } $this->audit->log($tenantId, $actorUserId, 'ledger_entry.reversed', 'ledger_entry', $reversalId, 'success', [ 'reversal_of_entry_id' => $ledgerEntryId, ]); return $reversalId; } public function reversePaperSheet(int $tenantId, int $sheetId, int $actorUserId, ?string $reason = null): int { $this->assertTenantMembership($tenantId, $actorUserId); $statement = $this->pdo->prepare( 'SELECT DISTINCT le.id FROM ledger_entries le INNER JOIN consumption_events ce ON ce.id = le.reference_id AND le.reference_type = "consumption_event" INNER JOIN paper_sheet_lines psl ON psl.id = ce.paper_sheet_line_id WHERE le.tenant_id = :tenant_id AND psl.paper_sheet_id = :paper_sheet_id ORDER BY le.id ASC' ); $statement->execute([ 'tenant_id' => $tenantId, 'paper_sheet_id' => $sheetId, ]); $entryIds = $statement->fetchAll(PDO::FETCH_COLUMN); if ($entryIds === []) { throw new RuntimeException('Zu dieser Papierliste wurden keine verbuchten Eintraege gefunden.'); } $reversed = 0; foreach ($entryIds as $entryId) { if ($this->hasReversal($tenantId, (int) $entryId)) { continue; } $this->reverseLedgerEntry( $tenantId, (int) $entryId, $actorUserId, $reason !== null && trim($reason) !== '' ? $reason : 'Papierliste #' . $sheetId ); $reversed++; } if ($reversed === 0) { throw new RuntimeException('Diese Papierliste wurde bereits vollstaendig storniert.'); } $this->audit->log($tenantId, $actorUserId, 'paper_sheet.reversed', 'paper_sheet', $sheetId, 'success', [ 'reversed_entries' => $reversed, ]); return $reversed; } private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int { $statement = $this->pdo->prepare( '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; } private function assertTenantMember(int $tenantId, int $memberId): void { $statement = $this->pdo->prepare( 'SELECT id FROM members WHERE id = :id AND tenant_id = :tenant_id AND status = "active" LIMIT 1' ); $statement->execute([ 'id' => $memberId, 'tenant_id' => $tenantId, ]); if (!$statement->fetchColumn()) { throw new RuntimeException('Das gewaehlte Mitglied gehoert nicht zu diesem Mandanten.'); } } private function assertTenantProduct(int $tenantId, int $productId): void { $statement = $this->pdo->prepare( 'SELECT id FROM products WHERE id = :id AND tenant_id = :tenant_id AND is_active = 1 LIMIT 1' ); $statement->execute([ 'id' => $productId, 'tenant_id' => $tenantId, ]); if (!$statement->fetchColumn()) { throw new RuntimeException('Das gewaehlte Produkt gehoert nicht zu diesem Mandanten.'); } } private function assertTenantMembership(int $tenantId, int $userId): void { $statement = $this->pdo->prepare( 'SELECT id FROM tenant_memberships WHERE tenant_id = :tenant_id AND user_id = :user_id AND status = "active" LIMIT 1' ); $statement->execute([ 'tenant_id' => $tenantId, 'user_id' => $userId, ]); if (!$statement->fetchColumn()) { throw new RuntimeException('Der Benutzer ist fuer diesen Mandanten nicht aktiv.'); } } private function ledgerEntry(int $tenantId, int $ledgerEntryId): array { $statement = $this->pdo->prepare( 'SELECT * FROM ledger_entries WHERE id = :id AND tenant_id = :tenant_id LIMIT 1' ); $statement->execute([ 'id' => $ledgerEntryId, 'tenant_id' => $tenantId, ]); $entry = $statement->fetch(); if (!$entry) { throw new RuntimeException('Der angeforderte Vorgang wurde nicht gefunden.'); } return $entry; } private function hasReversal(int $tenantId, int $ledgerEntryId): bool { $statement = $this->pdo->prepare( 'SELECT id FROM ledger_entries WHERE tenant_id = :tenant_id AND reversal_of_entry_id = :reversal_of_entry_id LIMIT 1' ); $statement->execute([ 'tenant_id' => $tenantId, 'reversal_of_entry_id' => $ledgerEntryId, ]); return (bool) $statement->fetchColumn(); } }