diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 9a2730e9..2a001fde 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -3,11 +3,13 @@ namespace App\Exceptions; use App\Services\Payment\Payone\Exceptions\PayoneTransportException; +use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpException; use Throwable; @@ -54,9 +56,9 @@ class Handler extends ExceptionHandler * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response * - * @throws \Throwable + * @throws Throwable */ public function render($request, Throwable $exception) { @@ -73,9 +75,9 @@ class Handler extends ExceptionHandler * Convert an authentication exception into a response. * * @param \Illuminate\Http\Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ - protected function unauthenticated($request, \Illuminate\Auth\AuthenticationException $exception) + protected function unauthenticated($request, AuthenticationException $exception) { if ($request->expectsJson()) { return response()->json(['message' => $exception->getMessage()], 401); @@ -102,7 +104,7 @@ class Handler extends ExceptionHandler public function sendEmail(Throwable $exception) { try { - $e = FlattenException::create($exception); + $e = FlattenException::createFromThrowable($exception); $handler = new HtmlErrorRenderer(true); // boolean, true raises debug flag... $css = $handler->getStylesheet(); $content = $handler->getBody($e); diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index f6978216..7fff6a95 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -9,7 +9,9 @@ use App\Services\AboExternalCompletionService; use App\Services\AboItemHistoryService; use App\Services\AboOrderCart; use App\Services\AboRetryPaymentService; +use App\Services\HTMLHelper; use App\Services\Shop; +use Carbon\Carbon; use Request; class AboController extends Controller @@ -66,7 +68,7 @@ class AboController extends Controller { $months = []; for ($month = 1; $month <= 12; $month++) { - $months[$month] = \Carbon\Carbon::create(null, $month, 1)->locale(app()->getLocale())->translatedFormat('F'); + $months[$month] = Carbon::create(null, $month, 1)->locale(app()->getLocale())->translatedFormat('F'); } return $months; @@ -164,6 +166,16 @@ class AboController extends Controller return redirect(route('admin_abos_detail', [$id])); } + public function destroy($id) + { + $user_abo = UserAbo::findOrFail($id); + $user_abo->delete(); + + \Session()->flash('alert-success', __('abo.admin_delete_success')); + + return redirect(route('admin_abos')); + } + public function datatable() { @@ -220,7 +232,7 @@ class AboController extends Controller return $user_abo->next_date; }) ->addColumn('abo_interval', function (UserAbo $user_abo) { - return \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval); + return HTMLHelper::getAboStrLang($user_abo->abo_interval); }) ->addColumn('status', function (UserAbo $user_abo) { return $user_abo->getStatusFormated(); diff --git a/app/Http/Controllers/Portal/OrderController.php b/app/Http/Controllers/Portal/OrderController.php index 7c58752e..72ee8455 100644 --- a/app/Http/Controllers/Portal/OrderController.php +++ b/app/Http/Controllers/Portal/OrderController.php @@ -191,7 +191,7 @@ class OrderController extends Controller /** * Bestellung erneut in den Warenkorb legen. */ - public function myOrderCreate(int $id) + public function myOrderCreate($id) { $user = Auth::guard('customers')->user(); $shopping_order = ShoppingOrder::with('member.shop')->findOrFail($id); diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 128cc872..4cfb9577 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -15,12 +15,16 @@ use App\Models\UserAboOneTimeItem; use App\Repositories\AboRepository; use App\Services\AboHelper; use App\Services\AboItemHistoryService; +use App\Services\AboOneTimeService; use App\Services\AboOrderCart; use App\Services\AboPaymentMethodService; use App\Services\AboRecoveryCheckoutService; +use App\Services\HTMLHelper; use App\Services\OrderPaymentService; +use App\Services\Payment\PaymentProviderManager; use App\Services\Shop; use App\User; +use Carbon\Carbon; use Request; use Yard; @@ -64,8 +68,8 @@ class AboController extends Controller } if ($view === 'ot') { - $selectedYear = (int) \Request::get('year', now()->year); - $selectedMonth = (int) \Request::get('month', 0); + $selectedYear = (int) Request::get('year', now()->year); + $selectedMonth = (int) Request::get('month', 0); $baseQuery = UserAbo::where('member_id', \Auth::user()->id) ->where('status', '>=', 1) ->where('is_for', 'ot'); @@ -83,8 +87,8 @@ class AboController extends Controller 'isAdmin' => false, 'chartData' => AboHelper::getMonthlyAboCounts($baseQuery, $selectedYear, 'ot', \Auth::user()->id), 'chartYear' => $selectedYear, - 'chartYears' => \App\Services\HTMLHelper::getYearRange(2026), - 'chartMonths' => \App\Services\HTMLHelper::getTransMonths(), + 'chartYears' => HTMLHelper::getYearRange(2026), + 'chartMonths' => HTMLHelper::getTransMonths(), 'filterMonth' => $selectedMonth, 'filterYear' => $selectedYear, ]); @@ -168,8 +172,8 @@ class AboController extends Controller if ($changeLocked) { $message = __('abo.error_change_locked', [ - 'days' => (int) now()->diffInDays(\Carbon\Carbon::parse($user_abo->next_date), false), - 'lock' => \App\Repositories\AboRepository::getLockDaysChange(), + 'days' => (int) now()->diffInDays(Carbon::parse($user_abo->next_date), false), + 'lock' => AboRepository::getLockDaysChange(), ]); } // addProduct @@ -322,7 +326,7 @@ class AboController extends Controller } AboOrderCart::initYard($user_abo); - $message = (new \App\Services\AboOneTimeService)->handleAction($user_abo, $request->validated()); + $message = (new AboOneTimeService)->handleAction($user_abo, $request->validated()); AboOrderCart::initYard($user_abo); AboOrderCart::makeOrderYard($user_abo); @@ -719,6 +723,12 @@ class AboController extends Controller $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); + if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) { + \Session()->flash('alert-error', __('payment.abo_payments_frozen')); + + return redirect(route('user_abos_detail', [$view, $user_abo->id])); + } + if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) { \Session()->flash('alert-error', __('abo.card_update_link_not_allowed')); diff --git a/app/Http/Controllers/Web/AboCardUpdateController.php b/app/Http/Controllers/Web/AboCardUpdateController.php new file mode 100644 index 00000000..303c6e26 --- /dev/null +++ b/app/Http/Controllers/Web/AboCardUpdateController.php @@ -0,0 +1,93 @@ +resolveRedeemableLink($token); + + return view('portal.abo.card_update_link', [ + 'user_abo' => $link->user_abo, + 'token' => $token, + ]); + } + + public function store(UpdateAboCreditCardRequest $request, string $token, AboPaymentMethodService $service) + { + $link = $this->resolveRedeemableLink($token); + $user_abo = $link->user_abo; + + $returnUrls = [ + 'successurl' => route('abo_card_update_return', [$token, 'success']), + 'errorurl' => route('abo_card_update_return', [$token, 'error']), + 'backurl' => route('abo_card_update_return', [$token, 'cancel']), + ]; + + $result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'link'); + + if ($result->isApproved()) { + $link->forceFill(['used_at' => now()])->save(); + + return view('portal.abo.card_update_done', ['success' => true]); + } + + if ($result->isRedirect()) { + session(['abo_card_update.'.$user_abo->id => [ + 'carddata' => $result->carddata, + 'userid' => $result->userid, + ]]); + + return redirect()->away($result->redirectUrl); + } + + return redirect(route('abo_card_update_show', [$token])) + ->withErrors(['cc_cardpan' => $result->errorMessage ?? __('abo.card_update_failed')]); + } + + public function return(string $token, string $status, AboPaymentMethodService $service) + { + $link = $this->resolveRedeemableLink($token); + $user_abo = $link->user_abo; + + $stash = session()->pull('abo_card_update.'.$user_abo->id); + + if ($status === 'success' && is_array($stash)) { + $service->applyNewCard($user_abo, $stash['carddata'], (int) ($stash['userid'] ?? 0), 'link'); + $link->forceFill(['used_at' => now()])->save(); + + return view('portal.abo.card_update_done', ['success' => true]); + } + + $user_abo->forceFill(['card_update_pending_at' => null])->save(); + + return view('portal.abo.card_update_done', ['success' => false]); + } + + private function resolveRedeemableLink(string $token): AboCardUpdateLink + { + $link = AboCardUpdateLink::where('token_hash', AboCardUpdateLink::hashToken($token))->first(); + + if (! $link || ! $link->isRedeemable()) { + abort(410, __('abo.card_update_link_invalid')); + } + + $abo = $link->user_abo; + if (! $abo || ! $abo->canUpdatePaymentMethod()) { + abort(410, __('abo.card_update_link_invalid')); + } + + return $link; + } +} diff --git a/app/Http/Requests/Abo/UpdateAboCreditCardRequest.php b/app/Http/Requests/Abo/UpdateAboCreditCardRequest.php new file mode 100644 index 00000000..87de3502 --- /dev/null +++ b/app/Http/Requests/Abo/UpdateAboCreditCardRequest.php @@ -0,0 +1,61 @@ +> + */ + public function rules(): array + { + return [ + 'cc_cardholder_first' => ['required', 'string', 'max:50'], + 'cc_cardholder_last' => ['required', 'string', 'max:50'], + 'cc_cardpan' => ['required', 'string', 'regex:/^\d{12,19}$/'], + 'cc_cardexpiremonth' => ['required', 'string', 'regex:/^(0[1-9]|1[0-2])$/'], + 'cc_cardexpireyear' => ['required', 'string', 'digits:4'], + 'cc_cardtype' => ['required', 'string', 'in:V,M'], + 'cc_cardcvc2' => ['required', 'string', 'regex:/^\d{3,4}$/'], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'cc_cardholder_first.required' => __('abo.card_holder_required'), + 'cc_cardholder_last.required' => __('abo.card_holder_required'), + 'cc_cardpan.required' => __('abo.card_number_invalid'), + 'cc_cardpan.regex' => __('abo.card_number_invalid'), + 'cc_cardexpiremonth.required' => __('abo.card_expiry_invalid'), + 'cc_cardexpiremonth.regex' => __('abo.card_expiry_invalid'), + 'cc_cardexpireyear.required' => __('abo.card_expiry_invalid'), + 'cc_cardexpireyear.digits' => __('abo.card_expiry_invalid'), + 'cc_cardtype.in' => __('abo.card_type_invalid'), + 'cc_cardcvc2.required' => __('abo.card_cvc_invalid'), + 'cc_cardcvc2.regex' => __('abo.card_cvc_invalid'), + ]; + } + + /** + * Builds the validated, immutable DTO for the payment service. + */ + public function toDto(): CreditCardCheckData + { + return CreditCardCheckData::fromFormData($this->validated()); + } +} diff --git a/app/Mail/AboCardUpdateLinkMail.php b/app/Mail/AboCardUpdateLinkMail.php new file mode 100644 index 00000000..a5cd95a5 --- /dev/null +++ b/app/Mail/AboCardUpdateLinkMail.php @@ -0,0 +1,34 @@ +userAbo->shopping_user?->billing_firstname; + $salutation = $firstname ? __('email.hello').' '.$firstname : __('email.hello'); + + // Link zeigt immer auf das Kundenportal (in.mivita.care), unabhängig vom Versand-Kontext. + $link = rtrim(Util::getMyMivitaPortalUrl(), '/').'/abo/karte/'.$this->token; + + return $this->subject(__('abo.card_update_link_mail_subject')) + ->view('emails.abo_card_update_link') + ->with([ + 'salutation' => $salutation, + 'link' => $link, + 'greetings' => __('email.greetings'), + 'sender' => __('email.sender'), + ]); + } +} diff --git a/app/Models/AboCardUpdateLink.php b/app/Models/AboCardUpdateLink.php new file mode 100644 index 00000000..4a45712d --- /dev/null +++ b/app/Models/AboCardUpdateLink.php @@ -0,0 +1,82 @@ + 'datetime', + 'used_at' => 'datetime', + ]; + + public const DEFAULT_TTL_HOURS = 72; + + public function user_abo(): BelongsTo + { + return $this->belongsTo(UserAbo::class, 'user_abo_id'); + } + + public static function ttlHours(): int + { + $hours = (int) Setting::getContentBySlug('abo-card-update-link-ttl-hours'); + + return $hours > 0 ? $hours : self::DEFAULT_TTL_HOURS; + } + + public static function hashToken(string $token): string + { + return hash('sha256', $token); + } + + /** + * Creates a link and returns the model plus the plaintext token (only emitted here, + * never persisted). + * + * @return array{0: self, 1: string} + */ + public static function generateFor(UserAbo $abo, ?int $createdBy = null): array + { + $token = Str::random(48); + + $link = self::create([ + 'user_abo_id' => $abo->id, + 'token_hash' => self::hashToken($token), + 'expires_at' => now()->addHours(self::ttlHours()), + 'created_by_user_id' => $createdBy, + ]); + + return [$link, $token]; + } + + public function isRedeemable(): bool + { + return $this->used_at === null && $this->expires_at->isFuture(); + } +} diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 551a9ae8..298f6a1d 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -6,11 +6,14 @@ namespace App\Models; +use App\Services\Backoffice\BackofficeDashboardService; use App\Services\Util; use App\User; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -43,9 +46,9 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Illuminate\Support\Carbon|null $last_date * @property \Illuminate\Support\Carbon|null $next_date * @property-read User|null $member - * @property-read \App\Models\ShoppingUser $shopping_user - * @property-read Collection $user_abo_items - * @property-read Collection $one_time_items + * @property-read ShoppingUser $shopping_user + * @property-read Collection $user_abo_items + * @property-read Collection $one_time_items * @property-read int|null $user_abo_items_count * @property-read int|null $user_abo_orders_count * @@ -113,6 +116,9 @@ class UserAbo extends Model 'is_for', 'email', 'payone_userid', + 'payment_provider', + 'stripe_customer_id', + 'stripe_payment_method_id', 'clearingtype', 'wallettype', 'carddata', @@ -158,9 +164,9 @@ class UserAbo extends Model /** * Aktive Abos (laufend): active=true und kein terminaler/inaktiver Status * (4 = storniert, 5 = beendet, 6 = inaktiv). Spiegelt die Definition aus - * {@see \App\Services\Backoffice\BackofficeDashboardService}. + * {@see BackofficeDashboardService}. */ - public function scopeActiveAbo(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder + public function scopeActiveAbo(Builder $query): Builder { return $query ->where('active', true) @@ -172,7 +178,7 @@ class UserAbo extends Model * sind (Eingangsmonat = start_date). Bei month=0 (oder außerhalb 1–12) bleibt die * Abfrage unverändert. */ - public function scopeAddedInMonth(\Illuminate\Database\Eloquent\Builder $query, int $year, int $month): \Illuminate\Database\Eloquent\Builder + public function scopeAddedInMonth(Builder $query, int $year, int $month): Builder { if ($month < 1 || $month > 12) { return $query; @@ -301,7 +307,7 @@ class UserAbo extends Model return $pan; } - public function card_changes(): \Illuminate\Database\Eloquent\Relations\HasMany + public function card_changes(): HasMany { return $this->hasMany(UserAboCardChange::class)->latest(); } diff --git a/app/Models/UserAboCardChange.php b/app/Models/UserAboCardChange.php new file mode 100644 index 00000000..4a2c88b1 --- /dev/null +++ b/app/Models/UserAboCardChange.php @@ -0,0 +1,38 @@ + 'datetime', + ]; + + public function user_abo(): BelongsTo + { + return $this->belongsTo(UserAbo::class, 'user_abo_id'); + } +} diff --git a/app/Services/AboPaymentMethodService.php b/app/Services/AboPaymentMethodService.php new file mode 100644 index 00000000..a43fbe89 --- /dev/null +++ b/app/Services/AboPaymentMethodService.php @@ -0,0 +1,131 @@ + $returnUrls successurl/errorurl/backurl for the 3DS redirect. + */ + public function updateCreditCard(UserAbo $abo, CreditCardCheckData $card, array $returnUrls, ?string $source = null): CardUpdateResult + { + if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) { + return CardUpdateResult::error(__('payment.abo_payments_frozen')); + } + + if (! $abo->canUpdatePaymentMethod()) { + return CardUpdateResult::error(__('abo.card_update_not_allowed')); + } + + $shoppingUser = $abo->shopping_user; + if (! $shoppingUser) { + return CardUpdateResult::error(__('abo.card_update_failed')); + } + + $mode = $this->resolveMode($abo); + + // Konsistenz-Spur: vor externem PAYONE-Call + DB-Write setzen, damit ein Crash + // zwischen erfolgreichem PAYONE-Call und DB-Write auffindbar bleibt. + $abo->forceFill(['card_update_pending_at' => now()])->save(); + + $result = $this->payone->refreshAboCardToken($shoppingUser, $card, $returnUrls, $mode); + + if ($result->isApproved()) { + $this->applyNewCard($abo, $result->carddata, $result->userid, $source); + // 1-Cent-Reservierung freigeben; Fehler hier ist unkritisch (verfällt sonst). + $this->safelyReleasePreauth($result->txid, $mode); + + return $result; + } + + if ($result->isRedirect()) { + // Pending-Spur bleibt bestehen; Finalisierung nach 3DS-Rückkehr (Controller). + return $result; + } + + // Fehler: Spur zurücknehmen, kein DB-Update. + $abo->forceFill(['card_update_pending_at' => null])->save(); + + return $result; + } + + /** + * Writes the new card token atomically and idempotently and clears the pending trace. + * Also used by the 3-D-Secure return path after successful authentication. + * + * @param array $carddata + */ + public function applyNewCard(UserAbo $abo, array $carddata, int $userid, ?string $source = null): void + { + try { + DB::transaction(function () use ($abo, $carddata, $userid, $source) { + $abo->forceFill([ + 'carddata' => $carddata, + 'payone_userid' => $userid, + 'card_update_pending_at' => null, + ])->save(); + + $abo->card_changes()->create([ + 'masked_card' => UserAbo::formatMaskedCard($carddata), + 'source' => $source, + ]); + }); + } catch (Throwable $e) { + // PAYONE hat die neue Karte, der lokale DB-Write schlug fehl → Inkonsistenz: + // card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert. + MyLog::writeLog( + 'payment', + 'critical', + 'Kreditkarten-Update: DB-Write nach erfolgreichem PAYONE-Call fehlgeschlagen (Abo '.$abo->id.')', + ['user_abo_id' => $abo->id, 'payone_userid' => $userid, 'exception' => $e->getMessage()], + ); + + throw $e; + } + } + + private function safelyReleasePreauth(string $txid, string $mode): void + { + if ($txid === '') { + return; + } + + try { + $this->payone->releasePreauth($txid, $mode); + } catch (Throwable $e) { + MyLog::writeLog( + 'payone', + 'warning', + 'Preauth-Freigabe (capture amount=0) fehlgeschlagen, Reservierung verfällt', + ['txid' => $txid, 'error' => $e->getMessage()], + false, + ); + } + } + + private function resolveMode(UserAbo $abo): string + { + return $abo->initialShoppingPayment()?->mode ?: config('payone.defaults.mode'); + } +} diff --git a/app/Services/Payment/Payone/DTOs/CardUpdateResult.php b/app/Services/Payment/Payone/DTOs/CardUpdateResult.php new file mode 100644 index 00000000..004b0261 --- /dev/null +++ b/app/Services/Payment/Payone/DTOs/CardUpdateResult.php @@ -0,0 +1,71 @@ + $carddata New card token data (pseudocardpan, cardexpiredate, …). + */ + private function __construct( + public readonly string $status, + public readonly array $carddata = [], + public readonly int $userid = 0, + public readonly string $txid = '', + public readonly ?string $redirectUrl = null, + public readonly ?string $errorMessage = null, + ) {} + + /** + * @param array $carddata + */ + public static function approved(array $carddata, int $userid, string $txid): self + { + return new self(self::APPROVED, carddata: $carddata, userid: $userid, txid: $txid); + } + + /** + * @param array $carddata + */ + public static function redirect(string $redirectUrl, array $carddata, string $txid, int $userid = 0): self + { + return new self(self::REDIRECT, carddata: $carddata, userid: $userid, txid: $txid, redirectUrl: $redirectUrl); + } + + public static function error(string $errorMessage): self + { + return new self(self::ERROR, errorMessage: $errorMessage); + } + + public function isApproved(): bool + { + return $this->status === self::APPROVED; + } + + public function isRedirect(): bool + { + return $this->status === self::REDIRECT; + } + + public function isError(): bool + { + return $this->status === self::ERROR; + } +} diff --git a/app/Services/Payment/Payone/DTOs/CreditCardCheckData.php b/app/Services/Payment/Payone/DTOs/CreditCardCheckData.php new file mode 100644 index 00000000..082ee762 --- /dev/null +++ b/app/Services/Payment/Payone/DTOs/CreditCardCheckData.php @@ -0,0 +1,63 @@ + $data Keys as posted by the checkout/abo CC form (cc_*). + */ + public static function fromFormData(array $data): self + { + return new self( + cardholderFirstName: (string) ($data['cc_cardholder_first'] ?? ''), + cardholderLastName: (string) ($data['cc_cardholder_last'] ?? ''), + cardPan: (string) ($data['cc_cardpan'] ?? ''), + cardExpireMonth: (string) ($data['cc_cardexpiremonth'] ?? ''), + cardExpireYear: (string) ($data['cc_cardexpireyear'] ?? ''), + cardType: (string) ($data['cc_cardtype'] ?? ''), + cardCvc2: (string) ($data['cc_cardcvc2'] ?? ''), + ); + } + + /** + * Maps back to the cc_* parameter keys expected by + * {@see PayonePaymentService::checkCreditCard()}. + * + * @return array + */ + public function toCheckArray(): array + { + return [ + 'cc_cardholder_first' => $this->cardholderFirstName, + 'cc_cardholder_last' => $this->cardholderLastName, + 'cc_cardpan' => $this->cardPan, + 'cc_cardexpiremonth' => $this->cardExpireMonth, + 'cc_cardexpireyear' => $this->cardExpireYear, + 'cc_cardtype' => $this->cardType, + 'cc_cardcvc2' => $this->cardCvc2, + ]; + } +} diff --git a/app/Services/Payment/Payone/PayonePaymentService.php b/app/Services/Payment/Payone/PayonePaymentService.php index 4efa150a..57058ac6 100644 --- a/app/Services/Payment/Payone/PayonePaymentService.php +++ b/app/Services/Payment/Payone/PayonePaymentService.php @@ -6,12 +6,21 @@ use App\Models\ShoppingOrder; use App\Models\ShoppingPayment; use App\Models\ShoppingUser; use App\Models\UserAbo; +use App\Services\Payment\Payone\DTOs\CardUpdateResult; +use App\Services\Payment\Payone\DTOs\CreditCardCheckData; use App\Services\Payment\Payone\DTOs\PayonePaymentResult; use App\Services\Payone; use GuzzleHttp\Client; class PayonePaymentService { + /** + * Verification reservation amount (in cents) for a card-on-file preauthorization. + * Per PAYONE guidance for "initial transaction, followed by recurring payment" when + * the later amount is unknown: preauthorize 1 cent over 3-D Secure; never captured. + */ + public const CARD_VERIFICATION_AMOUNT = 1; + /** * @param array $defaults PAYONE merchant defaults from config('payone.defaults') * @param Client|null $client Optional Guzzle client; inject a MockHandler client in tests. @@ -125,4 +134,94 @@ class PayonePaymentService return PayonePaymentResult::fromResponse($response, $shoppingPayment); } + + /** + * Refreshes the stored credit-card token for an existing Abo without charging. + * + * Step 1: creditcardcheck (storecarddata=yes) → new pseudocardpan. + * Step 2: preauthorization as a customer-initiated transaction (customer_is_present=yes, + * recurrence=recurring, amount=1) → runs 3-D Secure and establishes a fresh + * recurring mandate. The 1 cent is reserved only and never captured (release via + * {@see releasePreauth()}). + * + * Returns an APPROVED result with the new card token + minted userid, a REDIRECT result + * (3-D Secure challenge) carrying the card token to stash for finalization, or an ERROR. + * + * @param array $returnUrls successurl/errorurl/backurl for the 3DS redirect. + */ + public function refreshAboCardToken( + ShoppingUser $shoppingUser, + CreditCardCheckData $card, + array $returnUrls, + string $mode, + ): CardUpdateResult { + $check = $this->checkCreditCard($card->toCheckArray(), $mode); + if (! $check->isValid()) { + return CardUpdateResult::error( + $check->customerMessage ?? $check->errorMessage ?? __('abo.card_update_failed') + ); + } + + $carddata = $check->rawResponse; + + $defaults = array_merge($this->defaults, ['mode' => $mode]); + $personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser); + + $method = [ + 'clearingtype' => 'cc', + 'wallettype' => '', + 'pseudocardpan' => $carddata['pseudocardpan'] ?? '', + 'cardexpiredate' => $carddata['cardexpiredate'] ?? '', + 'onlinebanktransfertype' => '', + 'request' => 'preauthorization', + ]; + + $verification = [ + 'recurrence' => 'recurring', + 'customer_is_present' => 'yes', + 'request' => 'preauthorization', + 'amount' => self::CARD_VERIFICATION_AMOUNT, + ]; + + $prepayment = [ + 'reference' => substr(uniqid('cu', false), 0, 16), + 'amount' => self::CARD_VERIFICATION_AMOUNT, + 'currency' => 'EUR', + ]; + + $request = PayoneRequestBuilder::buildRequest( + $defaults, $personalData, [], $method, $prepayment, $verification, $returnUrls, + ); + + $result = PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client)); + + if ($result->isApproved()) { + return CardUpdateResult::approved($carddata, $result->userid, $result->txid); + } + + if ($result->isRedirect()) { + return CardUpdateResult::redirect($result->redirectUrl ?? '', $carddata, $result->txid, $result->userid); + } + + return CardUpdateResult::error( + $result->customerMessage ?? $result->errorMessage ?? __('abo.card_update_failed') + ); + } + + /** + * Releases a card-verification preauthorization by capturing amount=0, which cancels + * the reserved funds at the acquirer. Alternatively the reservation expires by itself. + */ + public function releasePreauth(string $txid, string $mode, string $currency = 'EUR'): PayonePaymentResult + { + $request = array_merge($this->defaults, ['mode' => $mode], [ + 'request' => 'capture', + 'txid' => $txid, + 'sequencenumber' => 1, + 'amount' => 0, + 'currency' => $currency, + ]); + + return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client)); + } } diff --git a/database/migrations/2026_06_25_120000_add_card_update_pending_at_to_user_abos.php b/database/migrations/2026_06_25_120000_add_card_update_pending_at_to_user_abos.php new file mode 100644 index 00000000..5596e750 --- /dev/null +++ b/database/migrations/2026_06_25_120000_add_card_update_pending_at_to_user_abos.php @@ -0,0 +1,26 @@ +timestamp('card_update_pending_at')->nullable()->after('carddata'); + } + }); + } + + public function down(): void + { + Schema::table('user_abos', function (Blueprint $table) { + if (Schema::hasColumn('user_abos', 'card_update_pending_at')) { + $table->dropColumn('card_update_pending_at'); + } + }); + } +}; diff --git a/database/migrations/2026_06_26_120000_create_user_abo_card_changes_table.php b/database/migrations/2026_06_26_120000_create_user_abo_card_changes_table.php new file mode 100644 index 00000000..fb6fa2cb --- /dev/null +++ b/database/migrations/2026_06_26_120000_create_user_abo_card_changes_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('user_abo_id')->index(); + $table->string('masked_card')->nullable(); + $table->string('source')->nullable(); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_abo_card_changes'); + } +}; diff --git a/database/migrations/2026_06_26_130000_create_abo_card_update_links_table.php b/database/migrations/2026_06_26_130000_create_abo_card_update_links_table.php new file mode 100644 index 00000000..a568a7bd --- /dev/null +++ b/database/migrations/2026_06_26_130000_create_abo_card_update_links_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('user_abo_id')->index(); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expires_at'); + $table->timestamp('used_at')->nullable(); + $table->unsignedBigInteger('created_by_user_id')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('abo_card_update_links'); + } +}; diff --git a/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md b/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md index 1592e202..f263378e 100644 --- a/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md +++ b/dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md @@ -1,6 +1,16 @@ # Entwicklungsplan: Zahlungsanbieter & Abo-Subscription-Management -> **Status:** In Arbeit (Phase R abgeschlossen) · **Erstellt:** 2026-06-09 · **Zuletzt aktualisiert:** 2026-06-10 · **Briefing:** `docs/salescenter/Zahlungabieter.md` +> **Status:** In Arbeit (Phase R abgeschlossen) · **Erstellt:** 2026-06-09 · **Zuletzt aktualisiert:** 2026-06-25 · **Briefing:** `docs/salescenter/Zahlungabieter.md` + +> ⚠️ **Statushinweis (2026-06-25): PayPal-direkt zurückgestellt.** Der PayPal-Merchant hat die +> Subscriptions-API bis heute **nicht freigegeben**. Die Phasen **4–8 (Provider-Abstraktion, PayPal +> direkt, Migration)** sind damit **blockiert und pausiert**, bis die Freigabe vorliegt. Stattdessen +> wird die wichtigste unblockierte Anforderung – **Kreditkarte aktualisieren (Phase 1)** – isoliert und +> verschärft in einem eigenen Plan umgesetzt: +> **`dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md`**. +> Dort sind die Stakeholder-Festlegungen (nur Inhaber gibt Karte ein; CRM + Portal; Berater→Kunde-Link; +> Token-Refresh **ohne** Abbuchung) und die geklärte PAYONE-Frage K1 (kein `updateuser`, +> `pseudocardpan` genügt) dokumentiert. PAYONE-bezogene Phasen (0–3) bleiben hier gültig. ## Dokumentations-Hinweis (verbindlich) @@ -774,6 +784,11 @@ Nach Abschluss von R1–R7 + Review-Fixes wurden im Test-System echte Checkout-B ## 7. Phase 1 – Kreditkarte aktualisieren (PAYONE) ⭐ Priorität 1 +> 📌 **Ausgelagert & aktualisiert (2026-06-25):** Diese Phase wird eigenständig umgesetzt in +> `dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md` (mit +> Stakeholder-Festlegungen, geklärter K1-Frage und Berater→Kunde-Link). Der folgende Abschnitt ist die +> ursprüngliche Skizze; bei Abweichungen gilt der ausgelagerte Plan. + ### Ziel Authentifizierter User kann bei einem aktiven CC-Abo (`clearingtype=cc`) neue Kreditkartendaten hinterlegen. Die nächste Abbuchung nutzt den aktualisierten `pseudocardpan`. diff --git a/dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md b/dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md new file mode 100644 index 00000000..d2a719c3 --- /dev/null +++ b/dev/2026-06-25-kreditkarte-update/ENTWICKLUNGSPLAN-KREDITKARTE-AKTUALISIEREN.md @@ -0,0 +1,406 @@ +# Entwicklungsplan: Kreditkarte aktualisieren (PAYONE) + +> **Status:** In Arbeit (Schritt 1–5 erledigt; offen: Schritt 6 Sandbox-Smoke) · **Erstellt:** 2026-06-25 · **Scope:** Nur Kreditkarten-Update (PAYONE) +> **Übergeordnet:** `dev/2026-06-09-payments/ENTWICKLUNGSPLAN-ZAHLUNGSANBIETER.md` (Phase 1, herausgelöst & verschärft) +> **Voraussetzung erfüllt:** Phase R (PAYONE-Refactoring) abgeschlossen & live-verifiziert. + +## Umsetzungsstatus + +### Schritt 1 – DTO + Form Request + Autorisierungs-Invariante ✅ ERLEDIGT (2026-06-25) + +**Dateien:** +- `app/Services/Payment/Payone/DTOs/CreditCardCheckData.php` *(neu)* – immutables Input-DTO; `fromFormData()` / `toCheckArray()` (cc_*-Keys, die `PayonePaymentService::checkCreditCard()` erwartet). +- `app/Http/Requests/Abo/UpdateAboCreditCardRequest.php` *(neu)* – validiert die cc_*-Felder, `toDto()`, Meldungen via `abo.*`. +- `app/Models/UserAbo.php` – `isCreditCard()` + `canUpdatePaymentMethod()` (guard-agnostische Invariante). +- `resources/lang/{de,en}/abo.php` – 5 neue Karten-Validierungs-Keys. +- `tests/Feature/AboUpdateCreditCardRequestTest.php` *(neu)* – 14 Tests (DTO-Mapping, Validierung happy/invalid via Dataset, Invariante cc/Status). + +**Abweichung (statt `app/Policies/UserAboPolicy.php`):** Das Projekt nutzt **keine** registrierten Laravel-Policies, sondern **Inline-Eigentümerprüfungen** über **zwei Guards** (CRM = `web`/`App\User` → `User\AboController::checkPermissions`; Portal = `customers`/`App\Models\Customer` → `Portal\AboController::checkPortalPermission`). Eine dual-guard-Policy würde gegen Framework und Bestandskonvention arbeiten. Stattdessen: die **guard-agnostische** Regel als `UserAbo::canUpdatePaymentMethod()`; die guard-spezifische Eigentümerprüfung bleibt in den bestehenden Inline-Methoden (Schritt 3/4). + +**Hinweis CC-Eingabe:** Der bestehende `checkCreditCard`-Pfad konsumiert **rohe** `cc_*`-Felder (server-seitiges `creditcardcheck`); das Update-Formular spiegelt exakt diese Felder. `checkCreditCard()`-Signatur bleibt vorerst unverändert (kein Checkout-Bruch); `refreshAboCardToken()` in Schritt 2 nutzt `CreditCardCheckData::toCheckArray()`. + +**Tests:** 14 grün; Regression `AboPendingInitialPaymentTest` + `PayonePaymentServiceTest` grün (27 gesamt). `pint` grün. + +### Schritt 2 – Service-Schicht (PAYONE-Token-Refresh + Orchestrierung) ✅ ERLEDIGT (2026-06-25) + +**Dateien:** +- `app/Services/Payment/Payone/DTOs/CardUpdateResult.php` *(neu)* – Ergebnis-DTO mit Zuständen `approved(carddata,userid,txid)` / `redirect(url,carddata,txid)` / `error(message)`. +- `app/Services/Payment/Payone/PayonePaymentService.php` – `refreshAboCardToken(ShoppingUser, CreditCardCheckData, returnUrls, mode): CardUpdateResult` (creditcardcheck + `preauthorization` `amount=1`, `recurrence=recurring`, `customer_is_present=yes`); `releasePreauth(txid, mode)` (= `capture amount=0`); Konstante `CARD_VERIFICATION_AMOUNT=1`. +- `app/Services/AboPaymentMethodService.php` *(neu)* – `updateCreditCard(UserAbo, CreditCardCheckData, returnUrls)`: Invariante-Check → Pending-Spur setzen → Token-Refresh → bei APPROVED `applyNewCard()` (atomar) + Reservierung freigeben; bei REDIRECT Pending-Spur halten + Redirect zurückgeben; bei ERROR Spur zurücknehmen. `applyNewCard()` ist idempotent (auch für 3DS-Rückkehr) und löst bei DB-Write-Fehler nach PAYONE-OK einen **CRITICAL-Alert** aus (`card_update_pending_at` bleibt als Spur). +- `database/migrations/2026_06_25_120000_add_card_update_pending_at_to_user_abos.php` *(neu)* + `UserAbo` (fillable/cast). +- `resources/lang/{de,en}/abo.php` – `card_update_not_allowed`, `card_update_failed`. +- `tests/Feature/AboPaymentMethodServiceTest.php` *(neu)* – 5 Tests (APPROVED ohne Abbuchung, 3DS-REDIRECT ohne DB-Update, creditcardcheck-Fehler, preauth-Fehler, Nicht-CC-Abo). + +**Entscheidungen/Abweichungen:** +- `mode` (test/live) ohne Order: aus `UserAbo::initialShoppingPayment()?->mode`, Fallback `config('payone.defaults.mode')`. +- `refreshAboCardToken()` nimmt **`ShoppingUser`** (Personendaten) statt `UserAbo` – die `UserAbo`-DB-Schreibung bleibt in `AboPaymentMethodService` (saubere Schichtung: PAYONE-Service ohne Domänen-Persistenz). +- `releasePreauth`-Fehler ist unkritisch (Reservierung verfällt) → nur Warning-Log, kein Abbruch. + +**Offen für Schritt 3 (Controller):** 3DS-Rückkehr-Finalisierung – wo die Karten-Token-Daten über den Redirect zwischengespeichert werden (Session beim Selfservice, Link-Tabelle beim Berater→Kunde-Flow) und der finale `applyNewCard()`-Aufruf nach erfolgreicher 3DS-Authentifizierung. + +**Tests:** 5 grün; Gesamt-Regression (Schritt 1+2 + `PayonePaymentServiceTest` + `PayoneTransactionRecorderTest` + `AboPendingInitialPaymentTest`) **39 grün**. `pint` grün. + +### Schritt 3 – Portal-Selfservice (Kunde, in.mivita) ✅ ERLEDIGT (2026-06-25) + +**Dateien:** +- `app/Http/Controllers/Portal/AboController.php` – `editPaymentMethod($id)` (Formular, cc-only), `updatePaymentMethod(UpdateAboCreditCardRequest, $id, AboPaymentMethodService)` (3 Ausgänge: APPROVED→Flash+Redirect; REDIRECT→Karten-Token in Session stashen + `away(3DS)`; ERROR→Flash+zurück), `paymentMethodReturn($id, $status, …)` (3DS-Rückkehr: success→`applyNewCard()` aus Stash; sonst Pending-Spur zurücknehmen). +- `routes/domains/portal.php` – 3 Routen (`portal_abos_payment_method_edit|update|return`). +- `resources/views/portal/abo/payment_method.blade.php` + `_payment_method_form.blade.php` *(neu)* – CC-Formular (gespiegelt vom Checkout) + 0,01-EUR-Hinweis + aktuelle (maskierte) Karte. +- `resources/views/portal/abo/my_abo.blade.php` – Zahlungsmethoden-Karte mit Maske + „aktualisieren"-Button (nur cc + `canUpdatePaymentMethod()`). +- `app/Models/UserAbo.php` – `maskedCard()` (nur `truncatedcardpan`+`cardexpiredate`, nie PAN/CVC). +- `app/Services/Payment/Payone/DTOs/CardUpdateResult.php` + `PayonePaymentService` – `redirect()` trägt nun auch `userid`. +- `resources/lang/{de,en}/abo.php` – Form-/Status-Keys (`payment_method`, `update_payment_method`, `update_card_title`, `current_card`, `card_update_intro`, `card_update_success`). +- `tests/Feature/AboPortalPaymentMethodTest.php` *(neu)* – 4 Tests (Masken-Format, Formular-Render, 3DS-Rückkehr success→`applyNewCard`, cancel→Spur zurückgenommen). + +**Entscheidungen/Abweichungen:** +- **3DS-Rückkehr ohne Order-Webhook:** Für ein Karten-Update existiert keine `ShoppingOrder`, daher greift der order-zentrierte `PayoneWebhookHandler` nicht. Abschluss-Signal ist die **success-Return-URL**; der Karten-Token wird über den Redirect in der **Session** zwischengespeichert und bei Rückkehr per `applyNewCard()` geschrieben. Die `userid` aus der preauthorization wird mitgeführt; ist sie (REDIRECT) `0`, greift K1: die nächste Abbuchung mintet wie beim Erst-Checkout eine neue `userid` (Webhook-Recorder schreibt sie zurück). +- **Teststrategie konventionsgerecht:** Portal/CRM-Controller werden im Projekt nicht über HTTP getestet (domain-aware Routing + `customers`-Guard, vgl. `AboPendingInitialPaymentTest`). Daher: View-Render-Test + direkte Controller-Methoden-Aufrufe (`paymentMethodReturn`) + die bereits in Schritt 2 abgedeckte Service-Logik. `URL::resolveMissingNamedRoutesUsing` stubbt fehlende benannte Routen. + +**Tests:** 4 grün; Gesamt-Regression (Schritt 1–3 + PAYONE-Suite + `AboPendingInitialPaymentTest` + `PayoneWebhookHandlerTest`) **48 grün**. `pint` grün. + +### Schritt 3a – UX-Erweiterungen (Sicherheitshinweis + Änderungs-Historie) ✅ ERLEDIGT (2026-06-26) + +Nach Praxistest im Testsystem nachgezogen: +- **Sichtbare Sicherheits-Hinweisbox** im CC-Formular (`alert-info` + Schloss-Icon): Kartendaten werden hier **nicht gespeichert**, sondern SSL-verschlüsselt an PAYONE zur Verifizierung übermittelt (`abo.card_security_notice`). +- **Änderungs-Historie der Karte:** neue Tabelle `user_abo_card_changes` + Model `UserAboCardChange`; bei jedem erfolgreichen `applyNewCard()` wird ein Eintrag (Zeitpunkt, maskierte Karte, Quelle `portal`/später `crm`) **innerhalb der DB-Transaktion** geschrieben. Anzeige in der Zahlungsmethoden-Karte (`my_abo`) als Liste „DD.MM.YYYY HH:MM — 411111XXXXXX1234 (12/30)". `UserAbo::formatMaskedCard()` (static) + `card_changes()`-Relation. + +**Dateien:** `database/migrations/..._create_user_abo_card_changes_table.php`, `app/Models/UserAboCardChange.php`, `app/Models/UserAbo.php` (Relation + `formatMaskedCard`), `app/Services/AboPaymentMethodService.php` (Historie + `$source`), `app/Http/Controllers/Portal/AboController.php` (`'portal'`-Quelle), `resources/views/portal/abo/_payment_method_form.blade.php` + `my_abo.blade.php`, `resources/lang/{de,en}/abo.php`. + +**Tests:** Service- + Portal-Suite erweitert (Historien-Eintrag mit Maske/Quelle, Hinweisbox-Render) — **23 grün**. `pint` grün. Migration ausgeführt. + +### Schritt 4 – CRM-Selfservice (Berater, my.mivita) ✅ ERLEDIGT (2026-06-26) + +Spiegelt Schritt 3 für den **Berater als Inhaber seines eigenen Abos** (`view=me`). + +**Dateien:** +- `app/Http/Controllers/User/AboController.php` – `editPaymentMethod($view,$id)`, `updatePaymentMethod(UpdateAboCreditCardRequest,$view,$id,…)`, `paymentMethodReturn($view,$id,$status,…)` (Quelle `'crm'`) + privates **Inhaber-Gate** `assertOwnerCardEntry()`. +- `routes/domains/crm.php` – 3 Routen (`user_abos_payment_method_edit|update|return`, `{view}/{id}`). +- `resources/views/user/abo/payment_method.blade.php` *(neu)* – nutzt das geteilte `portal.abo._payment_method_form` mit CRM-`formAction`/`backUrl` (Partial dafür um `$formAction`/`$backUrl` parametrisiert, Portal-Defaults unverändert). +- `resources/views/user/abo/detail.blade.php` – Zahlungsmethoden-Karte (Maske + Button + Historie), nur `view=me` + cc. +- `tests/Feature/AboCrmPaymentMethodTest.php` *(neu)* – 2 Tests (3DS-Rückkehr `success`→`applyNewCard` mit Quelle `crm`; Kunden-Abo `ot`→403). + +**Entscheidung – striktes Inhaber-Gate (E1/E2 + O4):** `assertOwnerCardEntry()` lässt die direkte Karteneingabe **nur** zu, wenn `view=me`, `is_for=me` und `user_id === Auth::id()`. Damit ist **kein Admin-Bypass** (entgegen `checkPermissions`) und **keine Kunden-Abo-Eingabe** (`ot`) möglich – Kunden-Abos laufen über den **Berater→Kunde-Link (Schritt 5)**. + +**Tests:** 2 grün; Gesamt-Regression (Schritt 1–4 + PAYONE-Suite + `PortalOrderCreateTest`) **51 grün**. `pint` grün. + +### Schritt 5 – Berater→Kunde-Update-Link (login-frei) ✅ ERLEDIGT (2026-06-26) + +Berater verschickt einem Kunden (Kunden-Abo `ot`) einen Link; der **Kunde** gibt die Karte selbst ein. + +**Dateien:** +- `database/migrations/..._create_abo_card_update_links_table.php` + `app/Models/AboCardUpdateLink.php` *(neu)* – Token wird **nur als SHA-256-Hash** gespeichert (indexiert, unique, O(1)-Lookup; **kein bcrypt**), Einmalverwendung (`used_at`), TTL via `Setting('abo-card-update-link-ttl-hours')`, Default 72h. `generateFor()` gibt den Klartext-Token nur zurück (nie persistiert). +- `app/Mail/AboCardUpdateLinkMail.php` + `resources/views/emails/abo_card_update_link.blade.php` *(neu)* – Link zeigt via `Util::getMyMivitaPortalUrl()` immer auf die Kundendomain (in.mivita), unabhängig vom CRM-Versandkontext. +- `app/Http/Controllers/Web/AboCardUpdateController.php` *(neu)* – `show`/`store`/`return`, login-frei; `resolveRedeemableLink()` → **410 Gone** bei unbekannt/abgelaufen/benutzt/nicht aktualisierbar. Bei Erfolg `used_at` setzen; 3DS-Rückkehr wie Portal (Session-Stash). Quelle `'link'`. +- `routes/domains/portal.php` – 3 login-freie Routen `abo/karte/{token}[/return/{status}]`, je `throttle:10,1`. **Wichtig:** bewusst **außerhalb** der `auth:customers`-Gruppe platziert (sonst Login-Redirect für den nicht eingeloggten Kunden) – Bugfix 2026-06-26. +- `app/Http/Controllers/User/AboController.php` – `sendCardUpdateLink($view,$id)` (nur `ot` + cc + aktualisierbar) + CRM-Route `user_abos_payment_method_send_link`. +- `resources/views/user/abo/detail.blade.php` – „Update-Link senden"-Button für `ot`-Abos; Maske/Historie jetzt für `me` **und** `ot` sichtbar. +- `resources/views/portal/abo/card_update_link.blade.php` + `card_update_done.blade.php` *(neu)*; **Trust-Layout** `portal.layouts.public` *(neu)*: Portal-Header (Logo + Sprachwechsel), Footer (Impressum/Datenschutz über die öffentliche `portal.loading_modal`-Route) und „Zum Kundenportal"-Hinweis/Button (gegen Phishing-Eindruck, 2026-06-26). +- `resources/lang/{de,en}/abo.php` – Link-/Mail-/Ergebnis-Keys. +- `tests/Feature/AboCardUpdateLinkTest.php` *(neu)* – 6 Tests (Token nur als SHA-256 gespeichert, expired/used nicht einlösbar, 410, Store wendet Karte an + `used_at`, CRM-Versand `ot`→Mail+Link, `me`→kein Link). + +**Entscheidung/Sicherheit:** SHA-256-Lookup-Token (Review-Korrektur 5.3 #2 umgesetzt), Single-Use + TTL + Rate-Limiting; Karteneingabe weiterhin nur durch den Inhaber (hier den Kunden über den Link). +**Offen (Schritt 6):** Domain-Auflösung des Mail-Links im Live-Betrieb am Testsystem verifizieren (Kundendomain-Host korrekt). + +**Tests:** 6 grün; Gesamt-Regression (Schritt 1–5 + PAYONE-Suite) **50 grün**. `pint` grün. Migration ausgeführt. + +## Dokumentations-Hinweis (verbindlich) + +Wie im übergeordneten Plan: phasenweise abarbeiten, jede Phase nach Abschluss auf `ERLEDIGT` +(Datum, Dateien, Begründung), jede Änderung **programmatisch testen** (Pest), danach `./vendor/bin/pint`. + +--- + +## 1. Warum dieser eigenständige Plan + +PayPal-direkt (Phasen 4–8 des Hauptplans) ist **blockiert**: Vom PayPal-Merchant gibt es bis heute +keine Freigabe für die Subscriptions-API. Solange das offen ist, wird die direkte PayPal-Integration +**zurückgestellt**. Wir ziehen daher die für den Betrieb wichtigste, unblockierte Anforderung vor und +setzen sie isoliert um: **die Kreditkarte eines bestehenden Abos aktualisieren** (`clearingtype=cc`). + +**Nicht-Ziele dieses Plans:** PayPal direkt, SEPA-Mandatswechsel, Recovery-Zahlungslink bei +fehlgeschlagener Abbuchung (Hauptplan Phase 3), Provider-Abstraktion (Hauptplan Phase 4). +Berührt werden ausschließlich **CC-Abos über PAYONE**. + +--- + +## 2. Festgelegter Scope (Stakeholder-Entscheidungen 2026-06-25) + +| # | Entscheidung | Festlegung | +|---|--------------|------------| +| E1 | **Wer darf die Karte eingeben?** | **Ausschließlich der Abo-Inhaber selbst.** Niemand gibt fremde Kartendaten ein – kein Berater, kein Admin. | +| E2 | **Zwei Abo-Typen / zwei Kontexte** | Berater-Abos im **CRM** (`my.mivita`, `User\AboController`), Kunden-Abos im **Portal** (`in.mivita`, `Portal\AboController`). In beiden Kontexten aktualisiert der jeweilige **Inhaber** seine eigene Karte. | +| E3 | **Verifizierung** | Der Inhaber muss sich mit den **neuen** Kartendaten erneut **checkout-artig verifizieren** (PAYONE `creditcardcheck`, gleiches Formular wie Checkout). | +| E4 | **Berater → Kunde** | Zusätzlich kann ein Berater im CRM einem Kunden einen **Update-Link** senden. Der Kunde öffnet den Link und gibt **selbst** die neue Karte ein (Inhaber-Prinzip aus E1 bleibt gewahrt). | +| E5 | **Abbuchungsverhalten** | **Token-Refresh ohne Abbuchung.** Das Update bucht **nicht** ab; der neue Token wird gespeichert und erst bei der **nächsten regulären** Abo-Abbuchung verwendet. | + +--- + +## 3. Mechanismus (PAYONE ohne `updateuser`) + +Der wiederkehrende CC-Token eines Abos ist heute das Tupel +`UserAbo.payone_userid` + `UserAbo.carddata['pseudocardpan']` + `['cardexpiredate']` +(siehe [`PayonePaymentService::chargeAbo()`](../../app/Services/Payment/Payone/PayonePaymentService.php#L70-L127)). + +PAYONE-`updateuser` ist für den Live-Account **nicht bestätigt** (Hauptplan O2/O5). Entscheidung dort: +**keinen Update-Call, sondern neu erzeugen.** Für reines Karten-Update heißt das konkret: + +``` +1. Inhaber gibt neue Kartendaten ein (PAYONE-Client-API-Felder wie im Checkout) +2. creditcardcheck (storecarddata=yes) → neuer pseudocardpan, cardexpiredate, cardtype, truncatedcardpan +3. preauthorization (CIT, customer_is_present=yes, recurrence=recurring, amount=1 → 1 Cent) + → 3DS/SCA-Authentifizierung (ggf. REDIRECT zur ACS, Rückkehr wie Checkout) + → etabliert frisches Mandat für die neue Karte; reserviert NUR (kein capture) + → liefert neue userid + txid +4. Reservierung freigeben: capture mit amount=0 (storniert beim Acquirer) ODER verfallen lassen + → keine echte Abbuchung (1 Cent wird nicht belastet) +5. UserAbo.carddata + payone_userid mit den NEUEN Werten überschreiben (atomar, mit Pending-Spur) +6. Nächster Cron-Lauf (user:make_abo_order) bucht regulär über das neue Mandat ab. +``` + +### K1 GEKLÄRT (PAYONE-Doku + Code-Analyse, 2026-06-25) + +Recherche aus [docs.payone.com/payment-methods/credit-cards](https://docs.payone.com/payment-methods/credit-cards) +und dem Ist-Code: + +- **`pseudocardpan` ist der eigentliche, dauerhafte Kartentoken** (merchant-unique); **`userid` ist + laut PAYONE-Doku _optional_** (PAYONE-vergeben). +- **Es gibt kein `updateuser`/`updatecard`.** Kartenwechsel = neuer `creditcardcheck` → neuer + `pseudocardpan` (deckt sich mit Hauptplan O2/O5). +- Code-Beleg: Die `userid` entsteht erst als **Response** der ersten Autorisierung + ([`PayoneTransactionRecorder::recordFromResponse`](../../app/Services/Payment/Payone/PayoneTransactionRecorder.php#L33)) + und wird dann gespeichert. + +### Entscheidung (2026-06-25): direkt `preauthorization` (CIT, 3DS), kein MIT-Risiko + +Eine reine `creditcardcheck`-Swap-Variante (neuer `pseudocardpan`, dann beim nächsten Cron als +_merchant-initiated_ abbuchen) würde voraussetzen, dass PAYONE die Folge-Abbuchung auf einer **neuen, +noch nie SCA-authentifizierten** Karte ohne erneute 3DS akzeptiert. Aufgrund der PSD2-/SCA-Regeln ist +das **sehr wahrscheinlich nicht** der Fall – das wiederkehrende Mandat hängt am ursprünglichen +customer-initiated Vorgang (CIT) der **alten** Karte. Diese Variante wird daher **verworfen**. + +**Primärer Weg = `preauthorization` als CIT innerhalb des Update-Flows:** + +| Parameter | Wert | Zweck | +|-----------|------|-------| +| `request` | `preauthorization` | reserviert, **bucht nicht ab** (kein `capture`) | +| `pseudocardpan` | neu (aus `creditcardcheck`) | neuer Kartentoken | +| `customer_is_present` | `yes` | CIT – löst 3DS/SCA aus, Kunde authentifiziert sich aktiv | +| `recurrence` | `recurring` | etabliert das **neue** Folge-Mandat für künftige Abbuchungen | +| `amount` | `1` (1 Cent) | Verifizierungs-Reservierung; späterer Abo-Betrag noch nicht bekannt | + +Ergebnis: validierte Karte **+ frisches SCA-Mandat + neue `userid`/`pseudocardpan`** – **ohne echte +Abbuchung** (E5 erfüllt: Reservierung, kein Capture). Die `preauthorization` kann – wie 3DS im Checkout – +einen **REDIRECT** auf die ACS-Seite zurückgeben; der Update-Flow behandelt das wie der bestehende +Checkout-`REDIRECT`-Zweig ([`PayoneController::ResponseData`](../../app/Http/Controllers/Pay/PayoneController.php#L243-L255)) +und kehrt über eine Erfolgs-/Fehler-URL zurück. (Konstante `PREAUTHORIZATION` existiert bereits.) + +### K1b GEKLÄRT (PAYONE-Vorgabe, 2026-06-25) → amount=1, Freigabe via capture amount=0 + +PAYONE beschreibt für **„Initial transaction, followed by recurring payment"** explizit: ist der +spätere Betrag noch nicht bekannt, `preauthorization` mit **`amount=1`** (1 Cent), +`recurrence=recurring`, `customer_is_present=yes` über 3-D Secure senden. Der 1 Cent wird **nicht +belastet**. Freigabe der Reservierung: **`capture` mit `amount=0`** storniert das reservierte Geld beim +Acquirer – alternativ die Reservierung je nach PAYONE-Setup **verfallen** lassen. + +**Entscheidung:** `amount=1` für die `preauthorization`; nach erfolgreichem 3DS + DB-Update die +Reservierung per `capture amount=0` aktiv freigeben (Verfall als Fallback, falls Setup das Capture +nicht zulässt). UI-Hinweis für den Inhaber: „Zur Verifizierung kann eine Autorisierung von 0,01 EUR +erfolgen, die nicht belastet wird." + +--- + +## 4. Architektur + +### 4.1 Neue Dateien + +| Datei | Zweck | +|-------|-------| +| `app/Services/AboPaymentMethodService.php` | Orchestriert das Update: Policy-Check → `creditcardcheck` → `preauthorization` (CIT/3DS, kein capture) → bei 3DS-REDIRECT pausieren/zurückkehren → `UserAbo` atomar aktualisieren → optional Reversal → SysLog. Kein HTTP. | +| `app/Http/Requests/Abo/UpdateAboCreditCardRequest.php` | Form Request: validiert CC-Felder, baut ein typsicheres `CreditCardCheckData`-DTO (vgl. Hauptplan 5.3 #4). **Keine PAN/CVC im Log.** | +| `app/Services/Payment/Payone/DTOs/CreditCardCheckData.php` | Immutables Input-DTO für `PayonePaymentService` (CLI-fest, kein `$data['key']` auf ungeprüften Arrays). | +| ~~`app/Policies/UserAboPolicy.php`~~ → **Abweichung (siehe Umsetzungsstatus Schritt 1)** | Statt Laravel-Policy: guard-agnostische Invariante `UserAbo::canUpdatePaymentMethod()` (cc-only, nicht gelöscht, kein terminaler Status) + bestehende Inline-Eigentümerprüfungen je Guard. | +| `app/Models/AboCardUpdateLink.php` + Migration | Berater→Kunde-Link: `user_abo_id`, `token_hash` (indexiert, **SHA-256**), `expires_at`, `used_at`. | +| `app/Mail/AboCardUpdateLinkMail.php` | E-Mail mit dem Update-Link an den Kunden. | +| `app/Http/Controllers/Web/AboCardUpdateController.php` | Öffentlicher (login-freier) Einlöse-Flow des Berater-Links: Token prüfen → Formular → Update. | + +### 4.2 Erweiterungen bestehender Klassen + +| Datei | Änderung | +|-------|----------| +| [`PayonePaymentService`](../../app/Services/Payment/Payone/PayonePaymentService.php) | Neue Methode `refreshAboCardToken(UserAbo, CreditCardCheckData, string $mode): PayonePaymentResult` – kapselt `creditcardcheck` + `preauthorization` (CIT, `recurrence=recurring`, `amount=1`); Ergebnis kann `redirectUrl` (3DS) tragen. Plus `releasePreauth(txid)` (= `capture amount=0`). `checkCreditCard()` nimmt künftig das DTO statt rohem Array. | +| [`Portal\AboController`](../../app/Http/Controllers/Portal/AboController.php) | `editPaymentMethod($id)` (Formular) + `updatePaymentMethod(UpdateAboCreditCardRequest, $id)` – Inhaber-Selfservice. | +| [`User\AboController`](../../app/Http/Controllers/User/AboController.php) | `editPaymentMethod($view,$id)` + `updatePaymentMethod(...)` für **eigene** Berater-Abos; zusätzlich `sendCardUpdateLink($view,$id)` für Kunden-Abos (E4). | +| [`UserAbo`](../../app/Models/UserAbo.php) | Anzeige-Helfer `maskedCard()` (z. B. „Visa •••• 4242, gültig 12/27") aus `carddata['truncatedcardpan']`/`cardexpiredate`. Optional Spalte `card_update_pending_at` (siehe 6.2). | +| `routes/domains/portal.php` / `crm.php` / ggf. eigene Datei für den login-freien Link | Neue Routen (siehe 4.3). | +| Views: `portal/abo/my_abo.blade.php`, CRM-Abo-Detail (`admin.abo._detail` Alias) | Button „Zahlungsmethode aktualisieren" (nur bei `clearingtype=cc`) + maskierte Karteninfo. | + +### 4.3 Routen (Vorschlag, an bestehende Namensgebung angelehnt) + +```php +// portal.php (in.mivita – Kunde, eigenes Abo) +Route::get ('portal/my-subscriptions/payment-method/{id}', [AboController::class,'editPaymentMethod'])->name('portal_abos_payment_method_edit'); +Route::post('portal/my-subscriptions/payment-method/{id}', [AboController::class,'updatePaymentMethod'])->name('portal_abos_payment_method_update'); +Route::get ('portal/my-subscriptions/payment-method/{id}/return/{status}', [AboController::class,'paymentMethodReturn'])->name('portal_abos_payment_method_return'); // 3DS-Rückkehr + +// crm.php (my.mivita – Berater, eigenes Abo + Link an Kunde) +Route::get ('/user/abos/payment-method/{view}/{id}', 'User\AboController@editPaymentMethod')->name('user_abos_payment_method_edit'); +Route::post('/user/abos/payment-method/{view}/{id}', 'User\AboController@updatePaymentMethod')->name('user_abos_payment_method_update'); +Route::get ('/user/abos/payment-method/{view}/{id}/return/{status}', 'User\AboController@paymentMethodReturn')->name('user_abos_payment_method_return'); // 3DS-Rückkehr +Route::post('/user/abos/payment-method/send-link/{view}/{id}', 'User\AboController@sendCardUpdateLink')->name('user_abos_payment_method_send_link'); + +// öffentlicher Einlöse-Link (login-frei, throttled) – Kunde gibt selbst ein +Route::get ('abo/karte/{token}', [AboCardUpdateController::class,'show'])->middleware('throttle:10,1')->name('abo_card_update_show'); +Route::post('abo/karte/{token}', [AboCardUpdateController::class,'store'])->middleware('throttle:10,1')->name('abo_card_update_store'); +Route::get ('abo/karte/{token}/return/{status}', [AboCardUpdateController::class,'return'])->middleware('throttle:10,1')->name('abo_card_update_return'); // 3DS-Rückkehr +``` + +> **3DS-Rückkehr:** Die `successurl`/`errorurl`/`backurl` der `preauthorization` zeigen auf die jeweilige +> `…_return`-Route. Der Token/txid aus Schritt 2/3 wird bis zur Rückkehr zwischengespeichert +> (Session beim eingeloggten Selfservice; an der Link-Tabelle beim login-freien Flow), damit der +> finale `UserAbo`-Write nach erfolgreichem 3DS erfolgt. Muster: bestehender +> `checkout.transaction_status`-Flow. + +--- + +## 5. Flows + +### 5.1 Inhaber-Selfservice (Portal & CRM identisch) + +``` +Inhaber → "Zahlungsmethode aktualisieren" (nur clearingtype=cc sichtbar) + → CC-Formular (gleiche PAYONE-Client-API-Felder wie Checkout) + → POST updatePaymentMethod (UpdateAboCreditCardRequest) + → UserAboPolicy@updatePaymentMethod (Inhaber? cc? aktiv?) + → AboPaymentMethodService::update(userAbo, dto) + → PayonePaymentService::refreshAboCardToken() (creditcardcheck + preauthorization CIT/3DS) + ├─ status REDIRECT (3DS) → away(redirectUrl) zur ACS; Rückkehr auf Erfolgs-/Fehler-URL + │ (Token/txid bis zur Rückkehr zwischenspeichern, analog Checkout) + └─ status APPROVED → UserAbo atomar: carddata + payone_userid (neu) überschreiben + → Reservierung freigeben: capture amount=0 (Verfall als Fallback) + → SysLog (wer/wann, nur Token-/Maskendaten, nie PAN/CVC) + → Erfolg: "Karte aktualisiert. Nächste Abbuchung nutzt die neue Karte." (keine Abbuchung erfolgt) + → Fehler/Abbruch (auch 3DS abgelehnt): Formular erneut mit PAYONE-customermessage, KEIN DB-Update +``` + +### 5.2 Berater → Kunde (E4) + +``` +Berater (CRM, Kunden-Abo) → "Update-Link an Kunden senden" + → AboCardUpdateLink: token = Str::uuid(); token_hash = hash('sha256', token); TTL 72h; user_abo_id + → AboCardUpdateLinkMail an UserAbo.email (nur Klartext-Token im Link, nie persistiert) +Kunde → öffnet abo/karte/{token} + → Lookup via hash('sha256', token) (indexiert, O(1)); prüfen: nicht expired, nicht used, cc-Abo + → gleiches CC-Formular → store → AboPaymentMethodService::update() + → used_at setzen (Einmalverwendung) +``` + +--- + +## 6. Datenmodell + +### 6.1 Keine Pflicht-Migration für den Token + +`UserAbo.carddata` (JSON) und `payone_userid` existieren bereits – Update überschreibt nur deren Inhalt. + +### 6.2 Empfohlen: Konsistenz-Spur (Hauptplan-Review 5.3 #1) + +Externer PAYONE-Call und lokales DB-Update sind nicht in einer Transaktion gemeinsam atomar. Damit ein +Crash **nach** erfolgreichem PAYONE-Call, aber **vor** dem DB-Write auffindbar bleibt: + +- Spalte `user_abos.card_update_pending_at` (nullable timestamp): **vor** dem PAYONE-Call setzen, + **nach** erfolgreichem DB-Update zurücksetzen. +- Schlägt das DB-Update fehl → **CRITICAL-Log** (`payment`-Kanal) + Auto-Incident + (`PaymentIncident`, Provider `payone`) mit Bestandsmitteln. +- Reparaturpfad **idempotent**: erneutes Schreiben desselben `pseudocardpan` ist gefahrlos wiederholbar. +- Reihenfolge bleibt fix: **PAYONE OK → DB-Update** (nie umgekehrt). + +### 6.3 Link-Tabelle `abo_card_update_links` + +``` +id, user_abo_id (FK), token_hash VARCHAR(64) UNIQUE INDEX, expires_at, used_at NULL, created_by_user_id, timestamps +``` +Token wird **nur als SHA-256-Hash** gespeichert (indexierter Lookup, O(1)) – **nicht** bcrypt +(`Hash::make` erlaubt keinen direkten Lookup; vgl. Hauptplan-Review 5.3 #2). Klartext-Token nur im +E-Mail-Link. + +--- + +## 7. Sicherheit + +| Regel | Umsetzung | +|-------|-----------| +| Nur Inhaber gibt Karte ein (E1) | `UserAboPolicy@updatePaymentMethod`; Berater/Admin haben **keinen** Eingabepfad | +| Kein PAN/CVC in Logs/DB | Nur `pseudocardpan` + `cardexpiredate` + `truncatedcardpan` gespeichert; `redactPayload()` im Recorder greift bereits | +| Berater-Link | SHA-256-Hash in DB, TTL 72h (konfigurierbar via `Setting`, Default 72), Einmalverwendung (`used_at`), Bindung an `user_abo_id`, `throttle` auf Einlöse-Endpoint | +| Kein Login am Link | Token = Berechtigung; zusätzlich optional E-Mail-Abgleich mit `user_abo.email` | +| Atomarität | PAYONE OK → DB-Update mit `try/catch` + CRITICAL-Alert + Pending-Spur (6.2) | +| Nur CC-Abos | Policy + UI blenden alles außer `clearingtype=cc` aus | +| Test/Live-Trennung | `mode` aus dem Abo-Kontext durchreichen (wie `chargeAbo`) | + +--- + +## 8. Tests (Pest) + +| Test | Szenario | +|------|----------| +| `AboUpdateCreditCardTest` | Happy Path Portal: Mock PAYONE preauthorization APPROVED → `carddata`/`payone_userid` aktualisiert, **kein** capture/keine Abbuchung | +| | CRM: Berater aktualisiert **eigenes** Abo → ok | +| | 3DS: preauthorization REDIRECT → away(redirectUrl); Rückkehr mit Erfolg → DB-Update erst danach | +| | 3DS abgelehnt / Abbruch an Return-URL → **kein** DB-Update | +| | Auth: fremdes Abo → 403 (Policy) | +| | Nicht-CC-Abo (z. B. `wlt`) → 422/abgewiesen | +| | PAYONE ERROR/INVALID (creditcardcheck oder preauthorization) → **kein** DB-Update, Fehlermeldung | +| | DB-Update-Fehler nach PAYONE-OK → `card_update_pending_at` bleibt gesetzt + CRITICAL geloggt | +| `AboCardUpdateLinkTest` | Berater erzeugt Link → Mail versandt; Hash gespeichert, kein Klartext | +| | Einlösen gültiger Token → Update ok, `used_at` gesetzt | +| | Abgelaufener Token → 410; bereits benutzter → abgewiesen; throttle greift | + +--- + +## 9. Akzeptanzkriterien + +- [ ] Inhaber kann im **Portal** (Kunde) und **CRM** (Berater, eigenes Abo) die CC aktualisieren. +- [ ] Update bucht **nicht** ab (E5); nächster `user:make_abo_order`-Lauf nutzt die neue Karte. +- [ ] Berater kann einem Kunden einen **Update-Link** senden; Kunde aktualisiert selbst. +- [ ] Karteneingabe ausschließlich durch den Inhaber (E1) – kein Berater-/Admin-Eingabepfad. +- [ ] Maskierte Karteninfo im Abo-Detail sichtbar; **keine** PAN/CVC in Logs/DB. +- [ ] Fehlgeschlagener PAYONE-Call → kein Partial-Update; DB-Inkonsistenz wird detektiert & alarmiert. +- [ ] Berater-Link: SHA-256-Hash, TTL, Einmalverwendung, throttled. + +--- + +## 10. Umsetzungsreihenfolge + +``` +Schritt 1: CreditCardCheckData-DTO + UpdateAboCreditCardRequest + UserAboPolicy + ↓ +Schritt 2: PayonePaymentService::refreshAboCardToken() (creditcardcheck + preauthorization, kein capture) + + AboPaymentMethodService (+ Pending-Spur) + 3DS-Redirect/Return-Handling + ↓ +Schritt 3: Portal-Selfservice (Controller, Routes inkl. Return, View, Maske) → testbar end-to-end + ↓ +Schritt 4: CRM-Selfservice (Berater eigenes Abo) – gleiche Service-Schicht + ↓ +Schritt 5: Berater→Kunde-Link (Tabelle, Mail, login-freier Controller inkl. Return, throttle) + ↓ +Schritt 6: Testbench/Sandbox-Smoke: 3DS-Durchlauf; bestätigen amount=1 + capture amount=0 (Freigabe); + prüfen, dass 1 Cent nicht belastet wird und die nächste reguläre Abo-Abbuchung greift + ↓ +Schritt 7: Tests vervollständigen, pint, Live-Smoke im Test-System +``` + +**Sofort startbar:** K1 ist geklärt (preauthorization/CIT) – Schritt 1 und 2 können direkt umgesetzt +werden. Nur K1b (Reservierungsbetrag/Reversal) wird in Schritt 6 empirisch finalisiert und betrifft +nur den inneren Service-Call, nicht das Feature-Design. + +--- + +## 11. Offene Mini-Fragen + +| ID | Frage | Hinweis | +|----|-------|---------| +| K1 | ~~`userid` nötig / MIT ohne 3DS?~~ | **GEKLÄRT (2026-06-25):** kein `updateuser`; MIT ohne erneute SCA wird (PSD2) verworfen → **`preauthorization` als CIT/3DS, kein capture**. Siehe Abschnitt 3. | +| K1b | ~~Reservierungs-`amount` + Reversal vs. Verfall?~~ | **GEKLÄRT (2026-06-25, PAYONE-Vorgabe):** `amount=1` (1 Cent), Freigabe via `capture amount=0` (Verfall als Fallback). Siehe Abschnitt 3. | +| K2 | TTL Berater-Link 72h ok? | Default 72h, via `Setting` konfigurierbar (analog Hauptplan O12) | +| K3 | E-Mail-Abgleich am login-freien Link verpflichtend oder optional? | Empfehlung: optional, Token genügt; E-Mail-Abgleich als Härtung | +| K4 | Soll der CRM-Link-Versand auch für **Berater-eigene** Abos angeboten werden? | Vermutlich nein – Berater nutzt dort den direkten Selfservice | diff --git a/docs/2026-07-01.md b/docs/2026-07-01.md new file mode 100644 index 00000000..f13a13b9 --- /dev/null +++ b/docs/2026-07-01.md @@ -0,0 +1,41 @@ +Kurzfristige Anpassungen + +Abo + +nach den 6 Pflicht Ausführungen: +1 . Abo die Möglichkeit das Abo einen Monat zu pausieren. +Aber nur alle x Monate die möglicheit einen monat zu pausieren + + +2. abo kündigen ... hier aber der Hinweis, dass bei einem neuen Abschluss wieder die 6 Pflicht Ausführungen da sind.... + + +Team statistik + +Hier soll auch zu jedem Berater der Sponsor angezeigt werden sowie die gesamten Team Punkte + + +Dashboard +wer hat vom team neue Partner eingeschriebem +Wer vom team hat abos angeschlossen + volumen + + + + + + +1000 punkte shop +auch die eigenen punkte + + + +Team abos -> wie viele Abos sind in der Downline .... + +Sonstige Punkte -> Werden hier auch die Punkte aus der Registrierung etc. angezeigt?? + +Reg Für Berater prüfen. Es wird gesagt, dass manche den Link benutzen von einem Berater und dann trotzdem nicht der Sponsor richtig zugeordnet wird. +Am besten beide Registrierung, sauberer mitnehmen, wo die Registrierung kommt, + sollte ein Sponsor nicht richtig zugewiesen sein, soll bei der Zuweisung dann auch noch mal geprüft werden, dass die Punkte entsprechend richtig zugewiesen werden (200 punkte ) +Rosdmarie ... + + diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index 96cbec87..266a7454 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -81,6 +81,40 @@ @include('admin.abo._confirm_add_modal') + @if ($isAdmin) + + @endif + @if ($user_abo->status === 3 && $user_abo->active)