mivita/app/Services/AboRecoveryCheckoutService.php
Kevin 0660f4e0a0 Abo-Recovery: fehlgeschlagene Folgezahlung im Checkout nachholen
- Pay-now-Flow: bei fehlgeschlagenem Abo (Status 3) begleicht der Inhaber die
  offene Rechnung im Standard-Checkout mit frei waehlbarer, abo-faehiger Zahlart
  (nur eigenes Abo: Berater me-View + Kundenportal), payNow-Controller + Routen
- AboRecoveryCheckoutService baut die ShoppingInstance aus den Abo-Artikeln;
  neue Spalte shopping_orders.recovery_user_abo_id verknuepft Bestellung<->Abo
- Bei Erfolg: AboHelper::advanceAboAfterRecoveryPayment schreibt das Abo fort
  (Status->2, next_date) und uebernimmt die gewaehlte Zahlart als neues Token
  fuer kuenftige Abbuchungen
- Neue Pay-now-Fehlermail (AboPaymentFailedMail) ersetzt die generische
  Fehlermail im Abo-Cron; Link fuehrt auf die Abo-Detailseite (CRM bzw. Portal)
- Retry-/"extern erledigt"-Buttons nur noch fuer Admins; tote user/portal-
  Retry-Routen entfernt
- Checkout: Recovery-Hinweis statt "Abschluss des Abonnements", Lieferadresse
  vorbelegt, korrekter Zurueck-Link (Abo statt Shop)
- UserAbo::selfServiceDetailUrl zentralisiert die Abo-URL (Mail, Back-Links)
- Test-Command abo:test-recovery; Tests fuer payNow, Advance-Logik, Fehlermail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 10:38:05 +00:00

155 lines
6.1 KiB
PHP

<?php
namespace App\Services;
use App\Models\ShoppingInstance;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use Illuminate\Support\Facades\Log;
use Yard;
class AboRecoveryCheckoutService
{
/**
* Baut für ein fehlgeschlagenes (Status 3, aktives) Abo einen Recovery-Checkout auf:
* Warenkorb aus den aktuellen Abo-Artikeln, eine ShoppingInstance und die Checkout-URL,
* über die der Inhaber die offene Folgerechnung mit freier (abo-fähiger) Zahlart begleicht.
*
* Die Attribution wird durch Nachbilden der bewährten Checkout-Pfade korrekt gehalten:
* - Berater-eigenes Abo (is_for=me) → is_from=user_order, is_for=abo-me (payment=2, auth_user)
* - Kunden-Abo (is_for=ot) → is_from=shopping, is_for=abo-ot-customer (payment=6)
*
* @return string|null Checkout-URL oder null, wenn der Aufbau fehlschlug.
*/
public function buildCheckoutUrl(UserAbo $userAbo): ?string
{
AboHelper::ensureUserAboItemsFromLatestOrder($userAbo);
Yard::instance(AboOrderCart::INSTANCE)->destroy();
AboOrderCart::initYard($userAbo);
AboOrderCart::makeOrderYard($userAbo);
$yard = Yard::instance(AboOrderCart::INSTANCE);
if ($yard->content()->isEmpty()) {
Log::channel('abo_order')->error('AboRecoveryCheckoutService: Leerer Warenkorb, Abbruch', ['abo_id' => $userAbo->id]);
return null;
}
$shoppingUser = $userAbo->shopping_user;
$referenceOrder = $shoppingUser?->shopping_orders()->orderByDesc('id')->first();
$isMe = $userAbo->is_for === 'me';
$consultant = $isMe ? $userAbo->user : $userAbo->member;
$consultantShop = $consultant?->shop;
$userShopId = $referenceOrder?->user_shop_id ?? $consultantShop?->id;
if (! $userShopId) {
Log::channel('abo_order')->error('AboRecoveryCheckoutService: Kein user_shop_id ermittelbar', ['abo_id' => $userAbo->id]);
return null;
}
$identifier = $this->makeIdentifier();
ShoppingInstance::create([
'identifier' => $identifier,
'user_shop_id' => $userShopId,
'auth_user_id' => $isMe ? $userAbo->user_id : null,
'payment' => $isMe ? 2 : 6,
'subdomain' => $consultantShop?->getSubdomain(false) ?? url('/'),
'country_id' => $yard->getShippingCountryId() ?? $referenceOrder?->country_id,
'language' => $shoppingUser?->getLocale() ?? \App::getLocale(),
'amount' => $yard->totalWithShipping(2, '.', ''),
'status' => 0,
'shopping_user_id' => $userAbo->shopping_user_id,
'shopping_data' => $this->buildShoppingData($userAbo, $shoppingUser, $yard, $isMe),
'back' => $userAbo->selfServiceDetailUrl(),
]);
$yard->store($identifier);
return route('checkout.checkout_card', ['identifier' => $identifier]);
}
private function makeIdentifier(): string
{
do {
$identifier = Util::getToken();
} while (ShoppingInstance::where('identifier', $identifier)->exists());
return $identifier;
}
/**
* @return array<string, mixed>
*/
private function buildShoppingData(UserAbo $userAbo, ?ShoppingUser $shoppingUser, mixed $yard, bool $isMe): array
{
$data = [
'is_from' => $isMe ? 'user_order' : 'shopping',
'is_for' => $isMe ? 'abo-me' : 'abo-ot-customer',
'is_abo' => true,
'abo_interval' => (int) $userAbo->abo_interval,
'recovery_user_abo_id' => $userAbo->id,
'shopping_user_id' => $userAbo->shopping_user_id,
'user_price_infos' => $yard->getUserPriceInfos(),
'mode' => config('app.mode') === 'test' ? 'test' : 'live',
];
if ($shoppingUser) {
// Lieferadresse immer vorbelegen (mit Rechnungsadresse als Fallback), damit der Checkout
// keine erneute Eingabe verlangt: der me-Pfad liest shipping_* aus shopping_data
// (shoppingUserAuthData erzwingt same_as_billing=true), der ot-Pfad füllt den ShoppingUser
// komplett aus shopping_data (makeCustomerShoppingUser).
$data += $this->shippingFrom($shoppingUser);
if (! $isMe) {
$data += $this->billingFrom($shoppingUser);
$data['member_id'] = $userAbo->member_id;
$data['same_as_billing'] = $shoppingUser->same_as_billing;
}
}
return $data;
}
/**
* @return array<string, mixed>
*/
private function shippingFrom(ShoppingUser $u): array
{
return [
'shipping_salutation' => $u->shipping_salutation ?: $u->billing_salutation,
'shipping_company' => $u->shipping_company ?: $u->billing_company,
'shipping_firstname' => $u->shipping_firstname ?: $u->billing_firstname,
'shipping_lastname' => $u->shipping_lastname ?: $u->billing_lastname,
'shipping_address' => $u->shipping_address ?: $u->billing_address,
'shipping_address_2' => $u->shipping_address_2 ?: $u->billing_address_2,
'shipping_zipcode' => $u->shipping_zipcode ?: $u->billing_zipcode,
'shipping_city' => $u->shipping_city ?: $u->billing_city,
'shipping_phone' => $u->shipping_phone ?: $u->billing_phone,
'shipping_postnumber' => $u->shipping_postnumber,
];
}
/**
* @return array<string, mixed>
*/
private function billingFrom(ShoppingUser $u): array
{
return [
'billing_salutation' => $u->billing_salutation,
'billing_company' => $u->billing_company,
'billing_firstname' => $u->billing_firstname,
'billing_lastname' => $u->billing_lastname,
'billing_address' => $u->billing_address,
'billing_address_2' => $u->billing_address_2,
'billing_zipcode' => $u->billing_zipcode,
'billing_city' => $u->billing_city,
'billing_country_id' => $u->billing_country_id,
'billing_phone' => $u->billing_phone,
'billing_email' => $u->billing_email,
];
}
}