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>
238 lines
9 KiB
PHP
238 lines
9 KiB
PHP
<?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;
|
||
}
|
||
}
|