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
+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;
}
}