Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung

- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan
  (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs)
- Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich
  begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail)
- Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail
- Admin: Abo löschen (destroy) inkl. Test
- Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID
- VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt
- Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-08 15:56:19 +00:00
parent 63c36555a5
commit 671022a28d
36 changed files with 2366 additions and 26 deletions

View file

@ -81,6 +81,40 @@
@include('admin.abo._confirm_add_modal')
@if ($isAdmin)
<div class="modal fade" id="modal-abo-delete" tabindex="-1" role="dialog" aria-labelledby="modal-abo-delete-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
{!! Form::open(['action' => route('admin_abos_delete', [$user_abo->id]), 'method' => 'DELETE']) !!}
<div class="modal-header">
<h5 class="modal-title" id="modal-abo-delete-label">{{ __('abo.admin_delete_confirm_title') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-danger">{{ __('abo.admin_delete_confirm_copy') }}</div>
<table class="table table-sm mb-0">
<tr>
<td class="font-weight-bold">{{ __('navigation.abo') }}:</td>
<td>#{{ $user_abo->id }}</td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.amount') }}:</td>
<td>{{ $user_abo->getFormattedAmount() }} €</td>
</tr>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit" class="btn btn-danger">{{ __('abo.admin_delete_confirm_button') }}</button>
</div>
{!! Form::close() !!}
</div>
</div>
</div>
@endif
@if ($user_abo->status === 3 && $user_abo->active)
<div class="modal fade" id="modal-retry-abo-payment" tabindex="-1" role="dialog" aria-labelledby="modal-retry-abo-payment-label" aria-hidden="true">
<div class="modal-dialog" role="document">

View file

@ -0,0 +1,108 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ __('abo.card_update_link_mail_subject') }}</title>
<style type="text/css">
a { text-decoration: none; border: 0; outline: none; color: #919f7a; }
a:hover { color: #b6b600; }
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
td { text-align: left; }
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
table { border-collapse: separate !important; }
.headline { color: #37302d; font-size: 18px; }
.force-full-width { width: 100% !important; }
</style>
</head>
<body class="body" style="padding:0; margin:0; display:block; background:#f8f8f8; -webkit-text-size-adjust:none" bgcolor="#f8f8f8">
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
<tr>
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
<br>
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700" class="w320">
<tr>
<td align="left" valign="top">
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="text-align:center;">
<center>
<picture style="text-align: center">
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" style="border:none" width="230">
</picture>
</center>
<br>
</td>
</tr>
</table>
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
<tr>
<td class="headline">
<b>{{ __('abo.card_update_link_mail_subject') }}</b>
</td>
</tr>
<tr>
<td style="color:#37302d;line-height:1.6em;">
{{ $salutation }},
<br><br>
{{ __('abo.card_update_link_mail_intro') }}
</td>
</tr>
<tr>
<td>
<table style="background-color: #f6fdf5; border: 2px solid #b5c49b; border-radius: 8px; margin: 20px 0;" cellpadding="15" cellspacing="0" width="100%">
<tr>
<td style="text-align: center; padding-top: 10px;">
<a href="{{ $link }}" style="display: inline-block; background: #FFCC00; color: #37302d; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold; font-size: 14px;">
{{ __('abo.card_update_link_mail_button') }}
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="color:#7B7B7E; font-size: 13px; line-height:1.6em;">
{{ __('abo.card_update_link_mail_hint') }}
<br>
<a href="{{ $link }}" style="color:#7B7B7E; text-decoration: underline; word-break: break-all;">{{ $link }}</a>
</td>
</tr>
<tr>
<td style="color:#37302d;line-height:1.6em;">
<br>
{{ $greetings }}<br>
{{ $sender }}
</td>
</tr>
</table>
<center>
<table cellpadding="0" cellspacing="0" class="force-full-width" bgcolor="#f8f8f8" style="margin: 0 auto; text-align: center;line-height:1.6em;">
<tr>
<td style="color:#7B7B7E; font-size:14px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy1')}}</p>
<br>
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
<br>
</td>
</tr>
<tr>
<td style="color:#bbbbbb; font-size:12px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy2')}}</p>
<a href="https://www.mivita.care/datenschutz">{{ __('dataprotect.data_protect') }}</a> <br>
<p>{{__('email.footer_copy3')}}</p>
<br><br>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,107 @@
<div class="card">
<div class="card-body">
<h5 class="font-weight-bold">{{ __('abo.update_card_title') }}</h5>
@if(app(\App\Services\Payment\PaymentProviderManager::class)->aboPaymentsFrozen())
<div class="alert alert-warning mb-0">
<strong>{{ __('payment.abo_payments_frozen_title') }}</strong><br>
{{ __('payment.abo_payments_frozen') }}
</div>
<a href="{{ $backUrl ?? route('portal.my_subscriptions') }}" class="btn btn-default mt-3">{{ __('abo.back') }}</a>
@else
<p class="text-muted">{{ __('abo.card_update_intro') }}</p>
@if (isset($errors) && $errors->any())
<div class="alert alert-danger p-2">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
@if (Session::has('alert-error'))
<div class="alert alert-danger p-2">{{ Session::get('alert-error') }}</div>
@endif
@if ($user_abo->maskedCard())
<div class="alert alert-info p-2">
<i class="ion ion-md-card mr-2" style="font-size: 1.2rem; line-height: 1rem;"></i>
<span>{{ __('abo.current_card') }}: <strong>{{ $user_abo->maskedCard() }}</strong></span>
</div>
@endif
<form method="POST" action="{{ $formAction ?? route('portal_abos_payment_method_update', [$user_abo->id]) }}">
@csrf
<div class="form-group">
<label for="cc_cardtype">{{ __('payment.credit_card') }}*</label>
<select id="cc_cardtype" name="cc_cardtype" class="form-control custom-select">
<option value="V" @if (old('cc_cardtype') === 'V') selected @endif>{{ __('payment.VISA') }}
</option>
<option value="M" @if (old('cc_cardtype') === 'M') selected @endif>
{{ __('payment.Mastercard') }}</option>
</select>
</div>
<div class="row">
<div class="col-md-12">
<label>{{ __('payment.owner') }}*</label>
</div>
<div class="form-group col-md-6">
<input type="text" name="cc_cardholder_first" id="cc_cardholder_first" class="form-control"
value="{{ old('cc_cardholder_first') }}" placeholder="{{ __('First name') }}">
</div>
<div class="form-group col-md-6">
<input type="text" name="cc_cardholder_last" id="cc_cardholder_last" class="form-control"
value="{{ old('cc_cardholder_last') }}" placeholder="{{ __('Last name') }}">
</div>
</div>
<div class="row">
<div class="form-group col-md-12">
<label>{{ __('payment.credit_card_number') }}*</label>
<input type="text" name="cc_cardpan" id="cc_cardpan" class="form-control"
value="{{ old('cc_cardpan') }}" placeholder="{{ __('payment.credit_card_number') }}"
inputmode="numeric" autocomplete="off">
</div>
<div class="form-group col-md-6">
<label for="cc_cardexpiremonth">{{ __('payment.valid') }}
{{ __('validation.attributes.month') }}*</label>
<select id="cc_cardexpiremonth" name="cc_cardexpiremonth" class="form-control custom-select">
@foreach (range(1, 12) as $month)
@php($month = sprintf('%02d', $month))
<option value="{{ $month }}" @if (old('cc_cardexpiremonth') === $month) selected @endif>
{{ $month }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-6">
<label for="cc_cardexpireyear">{{ __('payment.valid') }}
{{ __('validation.attributes.year') }}*</label>
<select id="cc_cardexpireyear" name="cc_cardexpireyear" class="form-control custom-select">
@foreach (range(date('Y'), date('Y', strtotime('+10 years'))) as $year)
<option value="{{ $year }}" @if (old('cc_cardexpireyear') === (string) $year) selected @endif>
{{ $year }}</option>
@endforeach
</select>
</div>
<div class="form-group col-md-6">
<label for="cc_cardcvc2">{{ __('payment.verification_no') }}*</label>
<input type="text" name="cc_cardcvc2" id="cc_cardcvc2" class="form-control"
value="{{ old('cc_cardcvc2') }}" placeholder="CVC*" inputmode="numeric" autocomplete="off">
</div>
</div>
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="ion ion-md-lock mr-2" style="font-size: 1.5rem; line-height: 1;"></i>
<span>{{ __('abo.card_security_notice') }}</span>
</div>
<button type="submit" class="btn btn-primary mt-2">{{ __('abo.update_payment_method') }}</button>
<a href="{{ $backUrl ?? route('portal.my_subscriptions') }}" class="btn btn-default mt-2">{{ __('abo.back') }}</a>
</form>
@endif
</div>
</div>

View file

@ -0,0 +1,20 @@
@extends('portal.layouts.public')
@section('content')
<div class="text-center" style="max-width: 640px; margin: 0 auto;">
@if ($success)
<i class="ion ion-md-checkmark-circle text-success" style="font-size: 3rem;"></i>
<h4 class="font-weight-bold mt-3">{{ __('abo.card_update_done_success_title') }}</h4>
<p class="text-muted">{{ __('abo.card_update_done_success_copy') }}</p>
@else
<i class="ion ion-md-close-circle text-danger" style="font-size: 3rem;"></i>
<h4 class="font-weight-bold mt-3">{{ __('abo.card_update_done_failed_title') }}</h4>
<p class="text-muted">{{ __('abo.card_update_done_failed_copy') }}</p>
@endif
<p class="text-muted mt-3">{{ __('abo.card_update_portal_hint') }}</p>
<a href="{{ \App\Services\Util::getMyMivitaPortalUrl() }}" class="btn btn-outline-secondary btn-sm">
<i class="ion ion-md-home mr-1"></i>{{ __('abo.card_update_to_portal') }}
</a>
</div>
@endsection

View file

@ -0,0 +1,20 @@
@extends('portal.layouts.public')
@section('content')
<div style="max-width: 640px; margin: 0 auto;">
<h4 class="font-weight-bold mb-3">{{ __('abo.update_card_title') }}</h4>
@include('portal.abo._payment_method_form', [
'user_abo' => $user_abo,
'formAction' => route('abo_card_update_store', [$token]),
'backUrl' => \App\Services\Util::getMyMivitaPortalUrl(),
])
<div class="alert alert-light border mt-3 text-center">
<p class="mb-2 text-muted">{{ __('abo.card_update_portal_hint') }}</p>
<a href="{{ \App\Services\Util::getMyMivitaPortalUrl() }}" class="btn btn-secondary btn-sm">
<i class="ion ion-md-home mr-1"></i>{{ __('abo.card_update_to_portal') }}
</a>
</div>
</div>
@endsection

View file

@ -0,0 +1,12 @@
@extends('portal.layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('abo.update_card_title') }}
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span>
</h4>
@include('portal.abo._payment_method_form', ['user_abo' => $user_abo])
@endsection

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="{{ strtolower(\App::getLocale()) }}" class="default-style">
<head>
<title>{{ config('app.name') }}</title>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="IE=edge,chrome=1">
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="stylesheet" href="{{ asset('/fonts/roboto-pacifico.css?v=1') }}">
<link rel="stylesheet" href="{{ asset('/vendor/fonts/fontawesome.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/fonts/ionicons.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/fonts/linearicons.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/bootstrap.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/appwork.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/theme-corporate.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/colors.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/uikit.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/css/mycolors.css') }}">
<link rel="stylesheet" href="{{ asset('/vendor/libs/growl/growl.css') }}">
<script src="{{ asset('/vendor/js/layout-helpers.js') }}"></script>
<script src="{{ asset('/js/jquery.min.js') }}"></script>
</head>
<body>
<div class="page-loader">
<div class="bg-primary"></div>
</div>
@if (Session::has('alert-error'))
<script>
$(document).ready(function () {
$.growl.error({ title: "{{ __('error') }}", message: '{{ Session::get('alert-error') }}', location: 'tr' });
});
</script>
@endif
<!-- Header: Logo + Sprachwechsel (login-frei) -->
<nav class="layout-navbar navbar navbar-expand-lg align-items-lg-center bg-navbar-theme container-p-x" id="layout-navbar">
<a href="https://www.mivita.care">
<img src="{{ asset('/images/logo_mivita.png') }}" alt="mivita.care" style="width: 170px;">
</a>
<div class="navbar-nav align-items-center ml-auto" style="flex-direction: row;">
<div class="nav-item dropdown mr-1">
<a class="nav-link dropdown-toggle hide-arrow text-right" href="#" data-toggle="dropdown">
<i class="ion ion-ios-globe navbar-icon align-middle"></i>
<span class="badge badge-default">{{ __('website.language') }} :
<img style="height: 16px" width="16" src="{{ asset('images/flags/'.strtolower(\App::getLocale()).'.svg') }}" alt="lang">
</span>
</a>
<div class="dropdown-menu dropdown-menu-right">
@foreach (\App\Services\UserService::getTransChange() as $code => $name)
<a href="{{ route('translation', [$code]) }}" class="dropdown-item @if (\App::getLocale() === $code) active @endif">
<img style="height: 16px" width="16" src="{{ asset('images/flags/'.strtolower($code).'.svg') }}" alt="lang"> &nbsp; {{ $name }}
</a>
@endforeach
</div>
</div>
</div>
</nav>
<div class="container py-4">
@yield('content')
</div>
@include('portal.layouts.includes.layout-footer')
<script src="{{ asset('/vendor/libs/popper/popper.js') }}"></script>
<script src="{{ asset('/vendor/js/bootstrap.js') }}"></script>
<script src="{{ asset('/vendor/libs/growl/growl.js') }}"></script>
<script src="{{ asset('/js/custom.js') }}"></script>
@yield('scripts')
</body>
</html>

View file

@ -0,0 +1,16 @@
@extends('layouts.layout-2')
@section('content')
<h4 class="font-weight-bold py-2 mb-2">
{{ __('abo.update_card_title') }}
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span>
</h4>
@include('portal.abo._payment_method_form', [
'user_abo' => $user_abo,
'formAction' => route('user_abos_payment_method_update', [$view, $user_abo->id]),
'backUrl' => route('user_abos_detail', [$view, $user_abo->id]),
])
@endsection