Haerte Verwaltungsflows und Zahlungsabgleich

This commit is contained in:
2026-08-07 12:02:41 +02:00
parent 39f662d541
commit 0f263c3a19
9 changed files with 461 additions and 101 deletions
+45 -20
View File
@@ -2,6 +2,11 @@
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
require __DIR__ . '/dev-db.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
@@ -11,7 +16,7 @@ $password = 'BillingCapTest123!';
/**
* @param array<string,string> $cookies
* @return array{status:int, body:string}
* @return array{status:int, body:string, location:?string}
*/
function bc_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array
{
@@ -43,6 +48,7 @@ function bc_request(string $url, array &$cookies, string $method = 'GET', ?strin
$body = @file_get_contents($url, false, $context);
$responseHeaders = $http_response_header ?? [];
$status = 0;
$location = null;
foreach ($responseHeaders as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
@@ -51,9 +57,28 @@ function bc_request(string $url, array &$cookies, string $method = 'GET', ?strin
if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) {
$cookies[$m[1]] = $m[2];
}
if (preg_match('/^Location:\s*(.+)$/i', $header, $m) === 1) {
$location = trim($m[1]);
}
}
return ['status' => $status, 'body' => (string)$body];
return ['status' => $status, 'body' => (string)$body, 'location' => $location];
}
function bc_follow_redirect(array $response, array &$cookies, string $baseUrl): array
{
if ($response['status'] < 300 || $response['status'] >= 400 || $response['location'] === null) {
return $response;
}
$location = (string)$response['location'];
if (preg_match('~^https?://~i', $location) === 1) {
$url = $location;
} else {
$url = $baseUrl . '/' . ltrim($location, '/');
}
return bc_request($url, $cookies);
}
function bc_csrf(string $body): string
@@ -121,38 +146,38 @@ bc_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
// 9 aktive Teilnehmer vorhanden (Cap 10) -> ein aktives Anlegen muss klappen (10/10).
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$body10 = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$body10 = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Zehntes Mitglied',
'email' => "billingcap-tenth-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
])), $cookies, $baseUrl);
bc_assert('10. aktives Mitglied bei Cap 10 wird angelegt', str_contains($body10['body'], 'wurde angelegt'), $failures, $passes);
// Jetzt bei 10/10 -> ein weiteres aktives Anlegen muss abgelehnt werden.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$body11 = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$body11 = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Elftes Mitglied',
'email' => "billingcap-eleventh-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
])), $cookies, $baseUrl);
bc_assert('11. aktives Mitglied bei Cap 10 wird abgelehnt', str_contains($body11['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
bc_assert('11. Mitglied wurde nicht in der DB angelegt', (int)$pdo->query("SELECT COUNT(*) FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-eleventh-{$suffix}@test.local'")->fetchColumn() === 0, $failures, $passes);
// Inaktives Anlegen bei vollem Cap muss weiterhin moeglich sein.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyInactive = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$bodyInactive = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Inaktives Mitglied',
'email' => "billingcap-inactive-{$suffix}@test.local",
'paypalname' => '',
]));
])), $cookies, $baseUrl);
bc_assert('Inaktives Mitglied bei vollem Cap wird angelegt', str_contains($bodyInactive['body'], 'wurde angelegt'), $failures, $passes);
$inactiveId = (int)$pdo->query("SELECT id FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-inactive-{$suffix}@test.local'")->fetchColumn();
@@ -160,45 +185,45 @@ bc_assert('Neu angelegtes Mitglied ist tatsaechlich inaktiv', (int)$pdo->query("
// Aktivieren des inaktiven Mitglieds bei vollem Cap muss abgelehnt werden.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyActivateBlocked = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$bodyActivateBlocked = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'aktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$inactiveId,
]));
])), $cookies, $baseUrl);
bc_assert('Aktivieren bei vollem Cap wird abgelehnt', str_contains($bodyActivateBlocked['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
bc_assert('Mitglied ist nach abgelehntem Aktivieren weiterhin inaktiv', (int)$pdo->query("SELECT active FROM participants WHERE id = {$inactiveId}")->fetchColumn() === 0, $failures, $passes);
// Ein aktives Mitglied deaktivieren (Platz schaffen), dann klappt Aktivieren.
$freeUpId = $extraParticipantIds[0];
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'deaktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$freeUpId,
]));
])), $cookies, $baseUrl);
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyActivateOk = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$bodyActivateOk = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'aktivieren',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$inactiveId,
]));
])), $cookies, $baseUrl);
bc_assert('Aktivieren nach Platzschaffen (Deaktivierung) klappt', str_contains($bodyActivateOk['body'], 'wurde aktiviert'), $failures, $passes);
// bearbeitenspeichern: ein weiteres inaktives Mitglied anlegen, dann per
// Bearbeiten-Formular aktivieren versuchen, wieder bei vollem Cap (10/10).
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'anlegen',
'csrf_token' => bc_csrf($page['body']),
'name' => 'Editier-Testmitglied',
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
]));
])), $cookies, $baseUrl);
$editId = (int)$pdo->query("SELECT id FROM participants WHERE tenant_id = {$tenantId} AND email = 'billingcap-edit-{$suffix}@test.local'")->fetchColumn();
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyEditBlocked = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$bodyEditBlocked = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'bearbeitenspeichern',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$editId,
@@ -206,19 +231,19 @@ $bodyEditBlocked = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, '
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
'aktiv' => '1',
]));
])), $cookies, $baseUrl);
bc_assert('bearbeitenspeichern-Reaktivierung bei vollem Cap wird abgelehnt', str_contains($bodyEditBlocked['body'], 'maximal 10 aktive Mitglieder'), $failures, $passes);
// Editieren ohne Statuswechsel (bleibt inaktiv) muss weiterhin klappen.
$page = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$bodyEditOk = bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$bodyEditOk = bc_follow_redirect(bc_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'aktion' => 'bearbeitenspeichern',
'csrf_token' => bc_csrf($page['body']),
'mitgliedID' => (string)$editId,
'name' => 'Editier-Testmitglied Neu',
'email' => "billingcap-edit-{$suffix}@test.local",
'paypalname' => '',
]));
])), $cookies, $baseUrl);
bc_assert('bearbeitenspeichern ohne Aktivierung bei vollem Cap klappt', str_contains($bodyEditOk['body'], 'wurde gespeichert'), $failures, $passes);
// Aufraeumen
+25 -4
View File
@@ -2,6 +2,11 @@
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
/**
* Prueft die vier Kontofunktionen, die vor dem Go-Live ergaenzt wurden:
*
@@ -78,6 +83,22 @@ function konto_request(string $url, array &$cookies, string $method = 'GET', ?st
return ['status' => $status, 'body' => (string)$body, 'location' => $location];
}
function konto_follow_redirect(array $response, array &$cookies, string $baseUrl): array
{
if ($response['status'] < 300 || $response['status'] >= 400 || $response['location'] === null) {
return $response;
}
$location = (string)$response['location'];
if (preg_match('~^https?://~i', $location) === 1) {
$url = $location;
} else {
$url = $baseUrl . '/' . ltrim($location, '/');
}
return konto_request($url, $cookies);
}
/** @param array<string,string> $cookies */
function konto_csrf(string $url, array &$cookies): string
{
@@ -160,12 +181,12 @@ try {
// 3. Verifikation wird fuer Einladungen erzwungen
// ---------------------------------------------------------------
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$einladung = konto_follow_redirect(konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'aktion' => 'zugang_gewaehren',
'mitgliedID' => (string)$participantId,
'rolle' => 'member',
]));
])), $cookies, $baseUrl);
pruefe(
'Einladung ohne bestaetigte Adresse wird abgelehnt',
str_contains($einladung['body'], 'bestätigt sein')
@@ -179,12 +200,12 @@ try {
$pdo->prepare('UPDATE users SET email_verified_at = NOW() WHERE id = ?')->execute([$userId]);
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
$einladung = konto_follow_redirect(konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'aktion' => 'zugang_gewaehren',
'mitgliedID' => (string)$participantId,
'rolle' => 'member',
]));
])), $cookies, $baseUrl);
pruefe(
'Einladung mit bestaetigter Adresse funktioniert',
str_contains($einladung['body'], 'Zugang wurde gewährt')
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/imports.php';
function payment_matching_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
function payment_matching_insert_tenant(PDO $pdo, string $suffix, string $key): int
{
$stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)');
$stmt->execute(["payment-match-{$key}-{$suffix}", "Payment Match {$key}", 'active']);
return (int)$pdo->lastInsertId();
}
function payment_matching_insert_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName): int
{
$stmt = $pdo->prepare(
'INSERT INTO participants (tenant_id, display_name, email, email_norm, paypal_name, active) VALUES (?, ?, ?, ?, ?, 1)'
);
$stmt->execute([$tenantId, $displayName, $email, strtolower($email), $paypalName]);
return (int)$pdo->lastInsertId();
}
function payment_matching_cleanup(PDO $pdo, array $tenantIds): void
{
foreach ($tenantIds as $tenantId) {
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
}
}
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$failures = [];
$passes = 0;
$tenantIds = [];
try {
$tenantId = payment_matching_insert_tenant($pdo, $suffix, 'a');
$otherTenantId = payment_matching_insert_tenant($pdo, $suffix, 'b');
$tenantIds = [$tenantId, $otherTenantId];
$uniqueId = payment_matching_insert_participant(
$pdo,
$tenantId,
"Eindeutig {$suffix}",
"unique-{$suffix}@test.local",
"PayPal Unique {$suffix}"
);
payment_matching_insert_participant(
$pdo,
$tenantId,
"Doppelt {$suffix}",
"duplicate-one-{$suffix}@test.local",
null
);
payment_matching_insert_participant(
$pdo,
$tenantId,
"Doppelt {$suffix}",
"duplicate-two-{$suffix}@test.local",
null
);
payment_matching_insert_participant(
$pdo,
$tenantId,
"Paypal A {$suffix}",
"paypal-a-{$suffix}@test.local",
"PayPal Doppelt {$suffix}"
);
payment_matching_insert_participant(
$pdo,
$tenantId,
"Paypal B {$suffix}",
"paypal-b-{$suffix}@test.local",
"PayPal Doppelt {$suffix}"
);
$otherTenantUniqueId = payment_matching_insert_participant(
$pdo,
$otherTenantId,
"Doppelt {$suffix}",
"other-{$suffix}@test.local",
null
);
$uniqueByName = imports_find_participant($pdo, $tenantId, "Eindeutig {$suffix}");
payment_matching_assert(
'eindeutiger Anzeigename wird gefunden',
$uniqueByName !== null && $uniqueByName['participant_id'] === $uniqueId,
$failures,
$passes
);
$uniqueByPaypalName = imports_find_participant($pdo, $tenantId, "paypal unique {$suffix}");
payment_matching_assert(
'eindeutiger PayPal-Name wird case-insensitive gefunden',
$uniqueByPaypalName !== null && $uniqueByPaypalName['participant_id'] === $uniqueId,
$failures,
$passes
);
payment_matching_assert(
'doppelter Anzeigename bleibt unmatched',
imports_find_participant($pdo, $tenantId, "Doppelt {$suffix}") === null,
$failures,
$passes
);
payment_matching_assert(
'doppelter PayPal-Name bleibt unmatched',
imports_find_participant($pdo, $tenantId, "PayPal Doppelt {$suffix}") === null,
$failures,
$passes
);
$otherTenantMatch = imports_find_participant($pdo, $otherTenantId, "Doppelt {$suffix}");
payment_matching_assert(
'gleicher Name in anderem Mandanten bleibt eindeutig',
$otherTenantMatch !== null && $otherTenantMatch['participant_id'] === $otherTenantUniqueId,
$failures,
$passes
);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
fwrite(STDERR, "Payment matching check failed to run: {$e->getMessage()}\n");
payment_matching_cleanup($pdo, $tenantIds);
exit(1);
}
payment_matching_cleanup($pdo, $tenantIds);
if ($failures !== []) {
echo "\nPayment matching check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nPayment matching check passed with {$passes} assertions.\n";
+10 -1
View File
@@ -47,6 +47,8 @@ foreach ($tenants as $tenantIdRaw) {
$schwelleCents = (int)$settings['negative_warning_cents']; // <= 0
$intervallTage = max(1, (int)$settings['payment_reminder_interval_days']);
$jetzt = new DateTimeImmutable('now');
$tenantGesendet = 0;
$tenantUebersprungen = 0;
$teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
$letzteErinnerungen = saas_fetch_last_payment_reminders($pdo, $tenantId);
@@ -68,11 +70,13 @@ foreach ($tenants as $tenantIdRaw) {
try {
$letzte = new DateTimeImmutable($letzteErinnerungen[$participantId]);
if ($letzte->add(new DateInterval("P{$intervallTage}D")) > $jetzt) {
$tenantUebersprungen++;
$gesamtUebersprungen++;
continue;
}
} catch (Throwable $e) {
// Unparsebares Datum: sicherheitshalber ueberspringen.
$tenantUebersprungen++;
$gesamtUebersprungen++;
continue;
}
@@ -84,6 +88,7 @@ foreach ($tenants as $tenantIdRaw) {
if ($dryRun) {
echo "[dry-run] Mandant {$tenantId}: {$person['display_name']} <{$email}> offen " . saas_format_money_cents($debtCents) . " EUR\n";
$tenantGesendet++;
$gesamtGesendet++;
continue;
}
@@ -94,12 +99,16 @@ foreach ($tenants as $tenantIdRaw) {
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, null
);
if ($sendResult['ok']) {
$tenantGesendet++;
$gesamtGesendet++;
}
}
if (!$dryRun) {
app_audit_log($pdo, $tenantId, null, 'payment_reminder.batch', 'tenant', $tenantId, ['gesendet' => $gesamtGesendet]);
app_audit_log($pdo, $tenantId, null, 'payment_reminder.batch', 'tenant', $tenantId, [
'gesendet' => $tenantGesendet,
'uebersprungen' => $tenantUebersprungen,
]);
}
}