Bugfixes aus dem Code-Review: - XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und letzteneintraege.php durch feste Seitennamen ersetzt. - Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events) gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung. - Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen (plan_code aus dem Preis-lookup_key bei subscription.updated). - Post-Redirect-Get fuer jahresauswertung, mailversenden und die Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per Browser-Refresh. - Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich beim letzten Empfaenger, damit die Summe exakt stimmt. - Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen entfernt (Layout kommt aus header.php). - Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks. Neue Funktionen: - Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben). - Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard. - Automatische Zahlungserinnerung: opt-in pro Mandant ab der Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php. - CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage (mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung. - Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant- Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den Text-Wasserzeichen im Ausdruck). Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
154 lines
6.6 KiB
PHP
154 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/app/database.php';
|
|
require_once __DIR__ . '/app/billing.php';
|
|
require_once __DIR__ . '/app/audit.php';
|
|
require_once __DIR__ . '/app/dolibarr.php';
|
|
|
|
// Kein app_require_csrf()/saas_require_login(): Stripe ruft diesen
|
|
// Endpunkt unauthentifiziert von aussen auf. Die Echtheit wird
|
|
// ausschliesslich ueber die Signaturpruefung sichergestellt.
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
http_response_code(405);
|
|
exit('Nur POST erlaubt.');
|
|
}
|
|
|
|
$payload = file_get_contents('php://input') ?: '';
|
|
$signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
|
|
|
|
if ($signatureHeader === '' || !stripe_verify_webhook_signature($payload, $signatureHeader, stripe_webhook_secret())) {
|
|
http_response_code(400);
|
|
exit('Ungültige Signatur.');
|
|
}
|
|
|
|
$event = stripe_parse_event($payload);
|
|
if ($event === null) {
|
|
http_response_code(400);
|
|
exit('Ungültiges Ereignis.');
|
|
}
|
|
|
|
$pdo = app_db_pdo();
|
|
$object = $event['data'];
|
|
|
|
// Idempotenz: bereits verarbeitete Events (Stripe-Retry bei Timeout) werden
|
|
// mit 200 bestaetigt, aber nicht erneut gebucht - verhindert u. a. doppelte
|
|
// Dolibarr-Rechnungen bei wiederholtem invoice.paid.
|
|
if (billing_webhook_event_seen($pdo, $event['id'], $event['type'])) {
|
|
http_response_code(200);
|
|
echo 'ok (bereits verarbeitet)';
|
|
exit;
|
|
}
|
|
|
|
switch ($event['type']) {
|
|
case 'checkout.session.completed':
|
|
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
|
|
$planCode = (string)($object['metadata']['plan_code'] ?? '');
|
|
$knownPlans = billing_plans();
|
|
if ($tenantId !== null && isset($object['customer'], $object['subscription']) && isset($knownPlans[$planCode])) {
|
|
billing_update($pdo, $tenantId, [
|
|
'plan_code' => $planCode,
|
|
'subscription_status' => 'active',
|
|
'stripe_customer_id' => (string)$object['customer'],
|
|
'stripe_subscription_id' => (string)$object['subscription'],
|
|
]);
|
|
app_audit_log($pdo, $tenantId, null, 'billing.subscription_started', 'tenant', $tenantId, ['plan_code' => $planCode]);
|
|
}
|
|
break;
|
|
|
|
case 'customer.subscription.updated':
|
|
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
|
|
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
|
|
if ($tenantId !== null) {
|
|
$status = (string)($object['status'] ?? 'active');
|
|
$periodEnd = isset($object['current_period_end'])
|
|
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
|
|
: null;
|
|
$updateFields = [
|
|
'subscription_status' => $status,
|
|
'current_period_end' => $periodEnd,
|
|
];
|
|
|
|
// Tarifwechsel aus dem Stripe-Kundenportal lokal nachziehen: der
|
|
// aktuelle Preis steckt am Subscription-Item, per lookup_key wieder
|
|
// auf unseren Plan-Code abbildbar. Nur bei aktiver Subscription und
|
|
// eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen.
|
|
$lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? '');
|
|
$mappedPlan = billing_plan_code_by_lookup_key($lookupKey);
|
|
if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due'], true)) {
|
|
$updateFields['plan_code'] = $mappedPlan;
|
|
}
|
|
|
|
billing_update($pdo, $tenantId, $updateFields);
|
|
app_audit_log($pdo, $tenantId, null, 'billing.subscription_updated', 'tenant', $tenantId, [
|
|
'status' => $status,
|
|
'plan_code' => $updateFields['plan_code'] ?? null,
|
|
]);
|
|
}
|
|
break;
|
|
|
|
case 'customer.subscription.deleted':
|
|
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
|
|
$tenantId ??= billing_find_tenant_id_by_stripe_subscription($pdo, (string)($object['id'] ?? ''));
|
|
if ($tenantId !== null) {
|
|
billing_update($pdo, $tenantId, [
|
|
'plan_code' => 'free',
|
|
'subscription_status' => 'canceled',
|
|
]);
|
|
app_audit_log($pdo, $tenantId, null, 'billing.subscription_canceled', 'tenant', $tenantId);
|
|
}
|
|
break;
|
|
|
|
case 'invoice.payment_failed':
|
|
$tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? ''));
|
|
if ($tenantId !== null) {
|
|
billing_update($pdo, $tenantId, ['subscription_status' => 'past_due']);
|
|
app_audit_log($pdo, $tenantId, null, 'billing.payment_failed', 'tenant', $tenantId);
|
|
}
|
|
break;
|
|
|
|
case 'invoice.paid':
|
|
$tenantId = billing_find_tenant_id_by_stripe_customer($pdo, (string)($object['customer'] ?? ''));
|
|
if ($tenantId !== null) {
|
|
$amountPaidCents = (int)($object['amount_paid'] ?? 0);
|
|
app_audit_log($pdo, $tenantId, null, 'billing.invoice_paid', 'tenant', $tenantId, [
|
|
'amount_paid_cents' => $amountPaidCents,
|
|
]);
|
|
|
|
if ($amountPaidCents > 0) {
|
|
$billing = billing_fetch_or_init($pdo, $tenantId);
|
|
try {
|
|
$sync = dolibarr_sync_invoice_paid($pdo, $tenantId, (string)$billing['plan_code'], $amountPaidCents);
|
|
} catch (Throwable $e) {
|
|
$sync = ['ok' => false, 'invoice_id' => null, 'ref' => null, 'error' => $e->getMessage()];
|
|
}
|
|
|
|
if ($sync['ok']) {
|
|
app_audit_log($pdo, $tenantId, null, 'billing.dolibarr_invoice_created', 'tenant', $tenantId, [
|
|
'dolibarr_invoice_id' => $sync['invoice_id'],
|
|
'dolibarr_ref' => $sync['ref'],
|
|
]);
|
|
} else {
|
|
// Wird bewusst nicht als Fehler an Stripe zurueckgegeben (kein Retry
|
|
// ausgeloest) - Stripe hat das Geld bereits erfolgreich eingezogen,
|
|
// das ist unabhaengig vom Dolibarr-Spiegel. Fehler landet im Audit-
|
|
// Log fuer manuelle Nachbearbeitung.
|
|
app_audit_log($pdo, $tenantId, null, 'billing.dolibarr_invoice_failed', 'tenant', $tenantId, [
|
|
'error' => $sync['error'],
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// Unbehandelte Ereignistypen werden bewusst ignoriert, aber mit 200
|
|
// bestaetigt, damit Stripe sie nicht wiederholt zustellt.
|
|
break;
|
|
}
|
|
|
|
http_response_code(200);
|
|
echo 'ok';
|