mivita/app/Services/Abo/AboStatusExplainer.php
Kevin 03416b0afe Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit
paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der
Backfill nimmt das Aktivierungsdatum nur noch ohne jede
Registrierungsbestellung (offene Rechnung bleibt leer).
F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt
genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel
(Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste
uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig.
F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit;
eine Ausfuehrung loescht auch den paused_by_*-Grund.
F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits
committeten Test nachgezogen.
F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false).
Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig
verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal.

Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 08:59:45 +00:00

238 lines
9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Abo;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Repositories\AboRepository;
use Carbon\Carbon;
/**
* Uebersetzt Status, Grund und Zeitpunkte eines Abos in eine Aussage, die Admin,
* Berater und Kunde ohne Rueckfrage verstehen (Ticket A3): "Pausiert bis
* 24.10.2026 – vom Kunden am 12.09.2026", "Zahlung am 10.09.2026 fehlgeschlagen
* (Karte abgelaufen) – das Abo laeuft nicht, bis die Zahlung nachgeholt ist",
* "Gekuendigt am 01.08.2026 vom Berater".
*
* Quellen: `user_abos.status` / `status_reason` / `paused_until` (gepflegt
* durch {@see UserAbo::applyStatusReasonDefaults()} und die Pause im
* {@see AboRepository}), `user_abo_orders.error_message`
* (Fehlertext des Zahlungsanbieters) und als Rueckfall fuer Bestandsdaten das
* Statusprotokoll {@see UserAboStatusHistory}.
*/
class AboStatusExplainer
{
public function explain(UserAbo $abo): AboStatusInfo
{
$status = (int) $abo->status;
if ($abo->isPendingInitialPayment() || $status === 0) {
return $this->pendingPayment($abo);
}
return match ($status) {
3 => $this->paymentFailed($abo),
4 => $this->cancelled($abo),
5 => new AboStatusInfo(AboStatusInfo::STATE_FINISHED, __('abo.state_finished'), 'dark', __('abo.state_text_finished')),
6 => $this->deactivated($abo),
7 => new AboStatusInfo(AboStatusInfo::STATE_GRACE, __('abo.state_grace'), 'info', __('abo.state_text_grace')),
8 => new AboStatusInfo(AboStatusInfo::STATE_EXTERN_DONE, __('abo.state_extern_done'), 'info', __('abo.state_text_extern_done')),
2 => $abo->active ? $this->running($abo) : $this->deactivated($abo),
default => new AboStatusInfo(AboStatusInfo::STATE_UNKNOWN, $abo->getStatusType() ?: __('abo.state_unknown'), $abo->getStatusColor(), ''),
};
}
/**
* Kurzer Fehlertext aus einer Payone-/Stripe-Antwort fuer
* `user_abo_orders.error_message` — null, wenn die Antwort nichts Lesbares
* enthaelt.
*
* @param array<string, mixed>|null $response
*/
public static function paymentErrorMessage(?array $response): ?string
{
if (! $response) {
return null;
}
$message = $response['customermessage'] ?? $response['errormessage'] ?? $response['message'] ?? null;
$code = $response['errorcode'] ?? $response['decline_code'] ?? $response['code'] ?? null;
if (! is_string($message) || trim($message) === '') {
return null;
}
$text = trim($message);
if (is_scalar($code) && (string) $code !== '') {
$text = '['.$code.'] '.$text;
}
return mb_substr($text, 0, 255);
}
private function running(UserAbo $abo): AboStatusInfo
{
$pausedUntil = $abo->pausedUntil();
if ($pausedUntil !== null && $pausedUntil->gte(Carbon::today())) {
$actor = $this->actorFromReason($abo->status_reason, 'paused_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED);
$date = $abo->last_pause_date ? Carbon::parse($abo->getRawOriginal('last_pause_date')) : null;
return new AboStatusInfo(
AboStatusInfo::STATE_PAUSED,
__('abo.state_paused'),
'warning',
__('abo.state_text_paused', [
'until' => $pausedUntil->format(\Util::formatDateDB()),
'actor' => $this->actorLabel($actor),
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
]),
$date,
$actor,
);
}
$last = $this->rawDate($abo, 'last_date');
$next = $this->rawDate($abo, 'next_date');
$text = $last
? __('abo.state_text_active', ['last' => $last->format(\Util::formatDateDB()), 'next' => $next?->format(\Util::formatDateDB()) ?? '-'])
: __('abo.state_text_active_first', ['next' => $next?->format(\Util::formatDateDB()) ?? '-']);
return new AboStatusInfo(AboStatusInfo::STATE_ACTIVE, __('abo.state_active'), 'success', $text, $next);
}
private function paymentFailed(UserAbo $abo): AboStatusInfo
{
$date = $this->rawDate($abo, 'last_date');
$error = $this->latestErrorMessage($abo);
$text = __('abo.state_text_payment_failed', ['date' => $date ? $date->format(\Util::formatDateDB()) : '-']);
if ($error !== null) {
$text .= ' '.__('abo.state_text_payment_error', ['error' => $error]);
}
return new AboStatusInfo(AboStatusInfo::STATE_PAYMENT_FAILED, __('abo.state_payment_failed'), 'danger', $text, $date, null, $error);
}
private function cancelled(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'cancelled_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_CANCELLED);
$date = $this->rawDate($abo, 'cancel_date') ?? $this->historyDate($abo, UserAboStatusHistory::ACTION_CANCELLED);
return new AboStatusInfo(
AboStatusInfo::STATE_CANCELLED,
__('abo.state_cancelled'),
'dark',
__('abo.state_text_cancelled', [
'date' => $date ? $date->format(\Util::formatDateDB()) : '-',
'actor' => $this->actorLabel($actor),
]),
$date,
$actor,
);
}
private function deactivated(UserAbo $abo): AboStatusInfo
{
$actor = $this->actorFromReason($abo->status_reason, 'deactivated_by_')
?? $this->actorFromHistory($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$date = $this->historyDate($abo, UserAboStatusHistory::ACTION_DEACTIVATED);
$text = $date
? __('abo.state_text_deactivated', ['date' => $date->format(\Util::formatDateDB()), 'actor' => $this->actorLabel($actor)])
: __('abo.state_text_deactivated_no_date', ['actor' => $this->actorLabel($actor)]);
return new AboStatusInfo(AboStatusInfo::STATE_DEACTIVATED, __('abo.state_deactivated'), 'secondary', $text, $date, $actor);
}
private function pendingPayment(UserAbo $abo): AboStatusInfo
{
if ($abo->hasFailedInitialPayment()) {
$text = __('abo.pending_payment_failed_copy');
} elseif ($abo->hasInitialPaymentStatus()) {
$text = __('abo.pending_payment_processing_copy');
} else {
$text = __('abo.pending_payment_waiting_copy');
}
return new AboStatusInfo(AboStatusInfo::STATE_PENDING_PAYMENT, __('abo.state_pending_payment'), 'warning', $text);
}
private function latestErrorMessage(UserAbo $abo): ?string
{
$order = $abo->relationLoaded('user_abo_orders')
? $abo->user_abo_orders->sortByDesc('id')->first(fn (UserAboOrder $order) => (int) $order->status === 3)
: $abo->user_abo_orders()->where('status', 3)->orderByDesc('id')->first();
$message = $order?->error_message;
return is_string($message) && trim($message) !== '' ? $message : null;
}
private function actorFromReason(?string $reason, string $prefix): ?string
{
if (! $reason || ! str_starts_with($reason, $prefix)) {
return null;
}
return substr($reason, strlen($prefix)) ?: null;
}
private function actorFromHistory(UserAbo $abo, string $action): ?string
{
$entry = $this->latestHistory($abo, $action);
return $entry ? UserAbo::reasonActorFromView($entry->view) : null;
}
private function historyDate(UserAbo $abo, string $action): ?Carbon
{
return $this->latestHistory($abo, $action)?->created_at?->copy();
}
/**
* Letzter Protokolleintrag je Aktion — pro Abo und Aktion nur einmal geladen,
* aus der vorgeladenen Relation, wenn vorhanden (Listen: `with('status_histories')`).
*
* @var array<string, UserAboStatusHistory|null>
*/
private array $historyMemo = [];
private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory
{
$key = $abo->id.':'.$action;
if (array_key_exists($key, $this->historyMemo)) {
return $this->historyMemo[$key];
}
if ($abo->relationLoaded('status_histories')) {
$entry = $abo->status_histories
->filter(fn (UserAboStatusHistory $history) => $history->action === $action)
->sortByDesc('id')
->first();
} else {
$entry = $abo->status_histories()->where('action', $action)->orderByDesc('id')->first();
}
return $this->historyMemo[$key] = $entry;
}
private function actorLabel(?string $actor): string
{
return __('abo.actor_by_'.($actor ?: UserAbo::ACTOR_SYSTEM));
}
private function rawDate(UserAbo $abo, string $column): ?Carbon
{
$raw = $abo->getRawOriginal($column);
return $raw ? Carbon::parse($raw) : null;
}
}