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

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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'));

View file

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Models\AboCardUpdateLink;
use App\Services\AboPaymentMethodService;
/**
* Login-free credit-card update via a tokenized link (sent by a consultant to a customer).
* Card data is entered exclusively by the customer here; routes are rate-limited. The token
* is looked up by its SHA-256 hash and is single-use + TTL-bound.
*/
class AboCardUpdateController extends Controller
{
public function show(string $token)
{
$link = $this->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;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests\Abo;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAboCreditCardRequest extends FormRequest
{
public function authorize(): bool
{
// Eigentümer-/Guard-spezifische Prüfung erfolgt im Controller
// (User\AboController::checkPermissions bzw. Portal\AboController::checkPortalPermission),
// analog zu den übrigen Abo-Aktionen. Hier nur Format-Validierung.
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
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<string, string>
*/
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());
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Mail;
use App\Models\UserAbo;
use App\Services\Util;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class AboCardUpdateLinkMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public UserAbo $userAbo, public string $token) {}
public function build(): self
{
$firstname = $this->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'),
]);
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* One-time, time-limited link that lets an Abo owner (customer) update their credit card
* via PAYONE without logging in. The plaintext token lives only in the e-mail link; the
* database stores a SHA-256 hash for an indexed O(1) lookup (never bcrypt – that would
* forbid a direct lookup). Single-use via {@see $used_at}, TTL via {@see ttlHours()}.
*
* @property int $user_abo_id
* @property string $token_hash
* @property Carbon $expires_at
* @property Carbon|null $used_at
* @property int|null $created_by_user_id
*/
class AboCardUpdateLink extends Model
{
protected $table = 'abo_card_update_links';
protected $fillable = [
'user_abo_id',
'token_hash',
'expires_at',
'used_at',
'created_by_user_id',
];
protected $casts = [
'expires_at' => '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();
}
}

View file

@ -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<int, \App\Models\UserAboItem> $user_abo_items
* @property-read Collection<int, \App\Models\UserAboOneTimeItem> $one_time_items
* @property-read ShoppingUser $shopping_user
* @property-read Collection<int, UserAboItem> $user_abo_items
* @property-read Collection<int, UserAboOneTimeItem> $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();
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Audit entry for a credit-card token change on an Abo: when it was changed,
* the masked card, and from which context (portal/crm/link). Holds no PAN/CVC.
*
* @property int $user_abo_id
* @property string|null $masked_card
* @property string|null $source
* @property Carbon|null $created_at
*/
class UserAboCardChange extends Model
{
public const UPDATED_AT = null;
protected $table = 'user_abo_card_changes';
protected $fillable = [
'user_abo_id',
'masked_card',
'source',
];
protected $casts = [
'created_at' => 'datetime',
];
public function user_abo(): BelongsTo
{
return $this->belongsTo(UserAbo::class, 'user_abo_id');
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace App\Services;
use App\Models\UserAbo;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Payone\PayonePaymentService;
use Illuminate\Support\Facades\DB;
use Throwable;
/**
* Orchestrates updating the credit-card token of an existing Abo via PAYONE
* (creditcardcheck + preauthorization, no charge — see the Phase-1 plan
* dev/2026-06-25-kreditkarte-update/). The card data is entered exclusively by the
* Abo owner; guard-/owner-specific authorization happens in the calling controller,
* the card-only/active invariant in {@see UserAbo::canUpdatePaymentMethod()}.
*/
class AboPaymentMethodService
{
public function __construct(private readonly PayonePaymentService $payone) {}
/**
* Runs the card update. On success the new token is written atomically; a 3-D Secure
* challenge is surfaced as a REDIRECT outcome (finalized after return via
* {@see applyNewCard()}); failures leave the stored card untouched.
*
* @param array<string, string> $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<string, mixed> $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');
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Payment\Payone\DTOs;
/**
* Outcome of an Abo credit-card token refresh (creditcardcheck + preauthorization).
*
* Three states:
* - approved: card validated and SCA mandate established; carries the new card token
* data ({@see $carddata}), the freshly minted PAYONE $userid and the preauth $txid.
* - redirect: 3-D Secure challenge required; $redirectUrl points to the ACS. The
* $carddata/$txid are carried so the caller can stash them and finalize after return.
* - error: creditcardcheck or preauthorization failed; $errorMessage holds a
* customer-facing message.
*/
class CardUpdateResult
{
public const APPROVED = 'approved';
public const REDIRECT = 'redirect';
public const ERROR = 'error';
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Services\Payment\Payone\DTOs;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payone;
/**
* Immutable, validated credit-card input for a card-on-file update (Abo).
*
* Built from {@see UpdateAboCreditCardRequest} so the payment
* service never reads unvalidated request arrays. Raw PAN/CVC exist only inside this
* object on the way to the PAYONE creditcardcheck request and must never be logged
* (redaction is enforced in {@see Payone::sendRequest()}).
*/
class CreditCardCheckData
{
public function __construct(
public readonly string $cardholderFirstName,
public readonly string $cardholderLastName,
public readonly string $cardPan,
public readonly string $cardExpireMonth,
public readonly string $cardExpireYear,
public readonly string $cardType,
public readonly string $cardCvc2,
) {}
/**
* @param array<string, mixed> $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<string, string>
*/
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,
];
}
}

View file

@ -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<string, mixed> $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<string, string> $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));
}
}

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('user_abos', function (Blueprint $table) {
if (! Schema::hasColumn('user_abos', 'card_update_pending_at')) {
$table->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');
}
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('user_abo_card_changes')) {
return;
}
Schema::create('user_abo_card_changes', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('abo_card_update_links')) {
return;
}
Schema::create('abo_card_update_links', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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`.

View file

@ -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 |

41
docs/2026-07-01.md Normal file
View file

@ -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 ...

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

View file

@ -0,0 +1,59 @@
<?php
use App\Http\Controllers\Admin\AboController;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\Routing\Exception\RouteNotFoundException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function adminDeleteUser(): User
{
return User::forceCreate([
'email' => 'admin-abo-delete-'.uniqid().'@test.com',
'password' => Hash::make('secret'),
'admin' => 2,
'lang' => 'de',
]);
}
function makeUserAbo(): UserAbo
{
return UserAbo::create([
'user_id' => 1,
'shopping_user_id' => 1,
'is_for' => 'me',
'email' => 'kunde@example.com',
'payone_userid' => 123456,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 5,
'next_date' => now()->addDays(10),
]);
}
it('löscht ein Abo soft über deleted_at', function () {
$this->actingAs(adminDeleteUser());
$abo = makeUserAbo();
try {
(new AboController(app(AboRepository::class)))->destroy($abo->id);
} catch (RouteNotFoundException $e) {
// Domain-spezifische Routen sind im Test-Kontext nicht registriert – erwartet.
}
expect(UserAbo::find($abo->id))->toBeNull();
expect(UserAbo::withTrashed()->find($abo->id)->deleted_at)->not->toBeNull();
});
it('wirft eine Not-Found-Exception für ein nicht existierendes Abo', function () {
$this->actingAs(adminDeleteUser());
(new AboController(app(AboRepository::class)))->destroy(999999);
})->throws(ModelNotFoundException::class);

View file

@ -0,0 +1,167 @@
<?php
use App\Http\Controllers\User\AboController;
use App\Http\Controllers\Web\AboCardUpdateController;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Mail\AboCardUpdateLinkMail;
use App\Models\AboCardUpdateLink;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboPaymentMethodService;
use App\Services\Payment\Payone\PayonePaymentService;
use App\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function stubLinkRoutes(): void
{
URL::resolveMissingNamedRoutesUsing(
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
);
}
function linkCcAbo(string $isFor = 'ot'): UserAbo
{
$shoppingUser = ShoppingUser::create([
'auth_user_id' => 1,
'billing_firstname' => 'Karl',
'billing_lastname' => 'Kunde',
'billing_address' => 'Weg 1',
'billing_zipcode' => '11111',
'billing_city' => 'Stadt',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'billing_email' => 'kunde@example.com',
'same_as_billing' => true,
'mode' => 'test',
'wp_order_number' => 0,
]);
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => $shoppingUser->id,
'email' => 'kunde@example.com',
'is_for' => $isFor,
'clearingtype' => 'cc',
'wallettype' => '',
'payone_userid' => 111,
'carddata' => ['pseudocardpan' => 'OLD', 'truncatedcardpan' => '411111XXXXXX0000', 'cardexpiredate' => '2501'],
'abo_interval' => 5,
'status' => 2,
]);
}
// --- Model / token hashing ---
it('stores only the SHA-256 hash of the token, never the plaintext', function () {
$abo = linkCcAbo();
[$link, $token] = AboCardUpdateLink::generateFor($abo, 7);
expect($link->token_hash)->toBe(hash('sha256', $token))
->and($link->token_hash)->not->toBe($token)
->and($link->created_by_user_id)->toBe(7)
->and($link->isRedeemable())->toBeTrue();
// Plaintext-Token darf nirgends in der DB stehen.
expect(AboCardUpdateLink::where('token_hash', $token)->exists())->toBeFalse();
});
it('treats expired and used links as not redeemable', function () {
$abo = linkCcAbo();
[$expired] = AboCardUpdateLink::generateFor($abo);
$expired->forceFill(['expires_at' => now()->subHour()])->save();
expect($expired->fresh()->isRedeemable())->toBeFalse();
[$used] = AboCardUpdateLink::generateFor($abo);
$used->forceFill(['used_at' => now()])->save();
expect($used->fresh()->isRedeemable())->toBeFalse();
});
// --- Login-free controller ---
it('returns 410 Gone for an unknown, expired or used token', function () {
stubLinkRoutes();
expect(fn () => (new AboCardUpdateController)->show('does-not-exist'))
->toThrow(HttpException::class);
});
it('applies the new card and marks the link used on a successful submission', function () {
stubLinkRoutes();
$abo = linkCcAbo();
[$link, $token] = AboCardUpdateLink::generateFor($abo);
$ct = ['Content-Type' => 'text/plain;charset=UTF-8'];
$mock = new MockHandler([
new Response(200, $ct, "status=VALID\npseudocardpan=NEW_LINK\ncardexpiredate=3012\ntruncatedcardpan=555555XXXXXX9999"),
new Response(200, $ct, "status=APPROVED\ntxid=TX1\nuserid=999"),
new Response(200, $ct, 'status=APPROVED'), // release capture amount=0
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$service = new AboPaymentMethodService(new PayonePaymentService(
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
client: $client,
));
$request = UpdateAboCreditCardRequest::create('/abo/karte/'.$token, 'POST', [
'cc_cardholder_first' => 'Karl',
'cc_cardholder_last' => 'Kunde',
'cc_cardpan' => '4111111111111111',
'cc_cardexpiremonth' => '12',
'cc_cardexpireyear' => '2030',
'cc_cardtype' => 'V',
'cc_cardcvc2' => '123',
]);
$request->setContainer(app());
$request->validateResolved();
(new AboCardUpdateController)->store($request, $token, $service);
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('NEW_LINK')
->and($abo->payone_userid)->toEqual(999)
->and($abo->card_changes()->first()->source)->toBe('link')
->and($link->fresh()->used_at)->not->toBeNull();
});
// --- CRM: consultant sends link ---
it('lets a consultant send a card update link for a customer abo', function () {
stubLinkRoutes();
Mail::fake();
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
$abo = linkCcAbo('ot');
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
$this->actingAs($berater);
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('ot', $abo->id);
Mail::assertSent(AboCardUpdateLinkMail::class);
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeTrue();
});
it('does not send a link for the consultants own abo (me)', function () {
stubLinkRoutes();
Mail::fake();
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
$abo = linkCcAbo('me');
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
$this->actingAs($berater);
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('me', $abo->id);
Mail::assertNothingSent();
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeFalse();
});

View file

@ -0,0 +1,55 @@
<?php
use App\Http\Controllers\User\AboController;
use App\Repositories\AboRepository;
use App\Services\AboPaymentMethodService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function makeCrmController(): AboController
{
return new AboController(app(AboRepository::class));
}
function stubCrmRoutes(): void
{
URL::resolveMissingNamedRoutesUsing(
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
);
}
it('applies the stashed card on a successful 3-D Secure return for the owning consultant', function () {
stubCrmRoutes();
$abo = makeMeAbo(); // is_for=me, clearingtype=cc, status=2, user_id=consultant
$abo->forceFill(['carddata' => ['pseudocardpan' => 'OLD'], 'card_update_pending_at' => now()])->save();
$this->actingAs($abo->user);
session(['abo_card_update.'.$abo->id => [
'carddata' => ['pseudocardpan' => 'NEW_CRM', 'truncatedcardpan' => '444444XXXXXX1111', 'cardexpiredate' => '2812'],
'userid' => 888,
]]);
makeCrmController()->paymentMethodReturn('me', $abo->id, 'success', app(AboPaymentMethodService::class));
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('NEW_CRM')
->and($abo->payone_userid)->toEqual(888)
->and($abo->card_update_pending_at)->toBeNull()
->and($abo->card_changes()->count())->toBe(1)
->and($abo->card_changes()->first()->source)->toBe('crm')
->and($abo->card_changes()->first()->masked_card)->toBe('444444XXXXXX1111 (12/28)');
});
it('forbids card entry for a customer abo (ot) in the CRM', function () {
stubCrmRoutes();
$abo = makeMeAbo();
$this->actingAs($abo->user);
expect(fn () => makeCrmController()->paymentMethodReturn('ot', $abo->id, 'success', app(AboPaymentMethodService::class)))
->toThrow(HttpException::class);
});

View file

@ -7,9 +7,11 @@ use App\Models\UserAboOneTimeItem;
use App\Services\AboHelper;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(Tests\TestCase::class, RefreshDatabase::class);
uses(TestCase::class, RefreshDatabase::class);
afterEach(function () {
Carbon::setTestNow();
@ -102,19 +104,34 @@ describe('isOneTimeFeatureVisible', function () {
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
});
it('ist nicht sichtbar bei geschlossenem Fenster', function () {
it('ist für normale User nicht sichtbar bei geschlossenem Fenster', function () {
$user = new User;
$user->admin = 0;
$this->actingAs($user, 'user');
$abo = new UserAbo;
$abo->next_date = now()->addDays(30); // außerhalb des Fensters
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
});
it('ist für VIP (Admin) dauerhaft sichtbar, auch bei geschlossenem Fenster', function () {
$user = new User;
$user->admin = 1;
$this->actingAs($user, 'user');
$abo = new UserAbo;
$abo->next_date = now()->addDays(30); // außerhalb des Fensters
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
});
});
describe('UserAboOneTimeItem Model', function () {
it('definiert die Beziehung one_time_items auf UserAbo', function () {
$relation = (new UserAbo)->one_time_items();
expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\HasMany::class)
expect($relation)->toBeInstanceOf(HasMany::class)
->and($relation->getRelated())->toBeInstanceOf(UserAboOneTimeItem::class);
});

View file

@ -0,0 +1,177 @@
<?php
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Payone\PayonePaymentService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Builds a Guzzle client that returns the given PAYONE responses in order.
* Each entry is a key=>value map that must contain a 'status'.
*
* @param array<int, array<string, string|int>> $responses
*/
function makePayoneClientSequence(array $responses): Client
{
$queue = array_map(function (array $pairs) {
$body = implode("\n", array_map(fn ($v, $k) => "$k=$v", $pairs, array_keys($pairs)));
return new Response(200, ['Content-Type' => 'text/plain;charset=UTF-8'], $body);
}, $responses);
return new Client(['handler' => HandlerStack::create(new MockHandler($queue))]);
}
function makeCardService(Client $client): AboPaymentMethodService
{
$payone = new PayonePaymentService(
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
client: $client,
);
return new AboPaymentMethodService($payone);
}
function makeCcAbo(int $status = 2): UserAbo
{
$shoppingUser = ShoppingUser::create([
'auth_user_id' => 1,
'billing_firstname' => 'Max',
'billing_lastname' => 'Mustermann',
'billing_address' => 'Musterstraße 1',
'billing_zipcode' => '12345',
'billing_city' => 'Musterstadt',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'billing_email' => 'max@example.com',
'same_as_billing' => true,
'mode' => 'test',
'wp_order_number' => 0,
]);
return UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $shoppingUser->id,
'clearingtype' => 'cc',
'wallettype' => '',
'payone_userid' => 111,
'carddata' => ['pseudocardpan' => 'OLD_PSEUDO', 'cardexpiredate' => '2501'],
'abo_interval' => 5,
'status' => $status,
]);
}
function cardForm(): CreditCardCheckData
{
return CreditCardCheckData::fromFormData([
'cc_cardholder_first' => 'Max',
'cc_cardholder_last' => 'Mustermann',
'cc_cardpan' => '4111111111111111',
'cc_cardexpiremonth' => '12',
'cc_cardexpireyear' => '2030',
'cc_cardtype' => 'V',
'cc_cardcvc2' => '123',
]);
}
it('updates the stored token on a successful preauthorization without charging', function () {
$client = makePayoneClientSequence([
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012', 'truncatedcardpan' => '411111XXXXXX1111'],
['status' => 'APPROVED', 'txid' => 'TX-PRE', 'userid' => '555'],
['status' => 'APPROVED', 'txid' => 'TX-CAP'], // release: capture amount=0
]);
$abo = makeCcAbo();
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), [], 'portal');
expect($result->isApproved())->toBeTrue();
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('NEW_PSEUDO')
->and($abo->payone_userid)->toEqual(555)
->and($abo->card_update_pending_at)->toBeNull();
// Änderungs-Historie wird protokolliert (maskierte Karte + Quelle).
expect($abo->card_changes()->count())->toBe(1);
$change = $abo->card_changes()->first();
expect($change->masked_card)->toBe('411111XXXXXX1111 (12/30)')
->and($change->source)->toBe('portal');
});
it('surfaces a 3-D Secure redirect without touching the stored card', function () {
$client = makePayoneClientSequence([
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012'],
['status' => 'REDIRECT', 'txid' => 'TX-3DS', 'redirecturl' => 'https://acs.example/3ds'],
]);
$abo = makeCcAbo();
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), [
'successurl' => 'https://shop/return/success',
'errorurl' => 'https://shop/return/error',
'backurl' => 'https://shop/return/cancel',
]);
expect($result->isRedirect())->toBeTrue()
->and($result->redirectUrl)->toBe('https://acs.example/3ds')
->and($result->carddata['pseudocardpan'])->toBe('NEW_PSEUDO');
$abo->refresh();
// Karte unverändert; Pending-Spur bleibt für die 3DS-Rückkehr gesetzt.
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
->and($abo->payone_userid)->toEqual(111)
->and($abo->card_update_pending_at)->not->toBeNull();
});
it('leaves the stored card untouched when the credit card check fails', function () {
$client = makePayoneClientSequence([
['status' => 'ERROR', 'errorcode' => '863', 'errormessage' => 'Card declined', 'customermessage' => 'Karte abgelehnt'],
]);
$abo = makeCcAbo();
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
expect($result->isError())->toBeTrue()
->and($result->errorMessage)->toBe('Karte abgelehnt');
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
->and($abo->payone_userid)->toEqual(111)
->and($abo->card_update_pending_at)->toBeNull();
});
it('leaves the stored card untouched when the preauthorization is declined', function () {
$client = makePayoneClientSequence([
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012'],
['status' => 'ERROR', 'errorcode' => '307', 'errormessage' => 'Declined', 'customermessage' => 'Abgelehnt'],
]);
$abo = makeCcAbo();
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
expect($result->isError())->toBeTrue();
$abo->refresh();
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
->and($abo->card_update_pending_at)->toBeNull();
});
it('refuses to update a non-credit-card abo', function () {
$client = makePayoneClientSequence([['status' => 'VALID']]); // must not be reached
$abo = makeCcAbo();
$abo->forceFill(['clearingtype' => 'wlt'])->save();
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
expect($result->isError())->toBeTrue();
$abo->refresh();
expect($abo->card_update_pending_at)->toBeNull();
});

View file

@ -0,0 +1,127 @@
<?php
use App\Http\Controllers\Portal\AboController;
use App\Models\Customer;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\AboPaymentMethodService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function stubMissingRoutes(): void
{
URL::resolveMissingNamedRoutesUsing(
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
);
}
function makePortalAboFixture(array $cardOverride = []): array
{
$shoppingUser = ShoppingUser::create([
'auth_user_id' => 1,
'billing_firstname' => 'Karl',
'billing_lastname' => 'Kunde',
'billing_address' => 'Kundenweg 2',
'billing_zipcode' => '11111',
'billing_city' => 'Kundenstadt',
'billing_country_id' => 1,
'shipping_country_id' => 1,
'billing_email' => 'kunde@example.com',
'same_as_billing' => true,
'mode' => 'test',
'wp_order_number' => 0,
]);
$abo = UserAbo::create([
'user_id' => 1,
'shopping_user_id' => $shoppingUser->id,
'email' => 'kunde@example.com',
'clearingtype' => 'cc',
'wallettype' => '',
'payone_userid' => 111,
'carddata' => array_merge([
'pseudocardpan' => 'OLD_PSEUDO',
'truncatedcardpan' => '411111XXXXXX0000',
'cardexpiredate' => '2501',
], $cardOverride),
'abo_interval' => 5,
'status' => 2,
'card_update_pending_at' => now(),
]);
$customer = Customer::create([
'name' => 'Karl Kunde',
'email' => 'kunde@example.com',
'shopping_user_id' => $shoppingUser->id,
]);
return compact('shoppingUser', 'abo', 'customer');
}
it('formats the masked card from token data only', function () {
$abo = new UserAbo([
'clearingtype' => 'cc',
'carddata' => ['truncatedcardpan' => '411111XXXXXX1234', 'cardexpiredate' => '3012'],
]);
expect($abo->maskedCard())->toBe('411111XXXXXX1234 (12/30)');
$noToken = new UserAbo(['clearingtype' => 'cc', 'carddata' => ['pseudocardpan' => 'X']]);
expect($noToken->maskedCard())->toBeNull();
});
it('renders the portal credit-card form with current card and cc fields', function () {
stubMissingRoutes();
$abo = makePortalAboFixture()['abo'];
$html = view('portal.abo._payment_method_form', ['user_abo' => $abo])->render();
expect($html)
->toContain('name="cc_cardpan"')
->toContain('name="cc_cardcvc2"')
->toContain('name="cc_cardexpiremonth"')
->toContain('411111XXXXXX0000 (01/25)')
->toContain(__('abo.update_payment_method'))
->toContain(__('abo.card_security_notice'));
});
it('applies the stashed card on a successful 3-D Secure return', function () {
stubMissingRoutes();
$fixture = makePortalAboFixture();
$this->actingAs($fixture['customer'], 'customers');
session(['abo_card_update.'.$fixture['abo']->id => [
'carddata' => ['pseudocardpan' => 'NEW_PSEUDO', 'truncatedcardpan' => '555555XXXXXX9999', 'cardexpiredate' => '3011'],
'userid' => 777,
]]);
(new AboController)->paymentMethodReturn(
$fixture['abo']->id, 'success', app(AboPaymentMethodService::class)
);
$abo = $fixture['abo']->fresh();
expect($abo->carddata['pseudocardpan'])->toBe('NEW_PSEUDO')
->and($abo->payone_userid)->toEqual(777)
->and($abo->card_update_pending_at)->toBeNull()
->and($abo->card_changes()->count())->toBe(1)
->and($abo->card_changes()->first()->source)->toBe('portal')
->and($abo->card_changes()->first()->masked_card)->toBe('555555XXXXXX9999 (11/30)');
});
it('clears the pending trace and keeps the card on a cancelled 3-D Secure return', function () {
stubMissingRoutes();
$fixture = makePortalAboFixture();
$this->actingAs($fixture['customer'], 'customers');
(new AboController)->paymentMethodReturn(
$fixture['abo']->id, 'cancel', app(AboPaymentMethodService::class)
);
$abo = $fixture['abo']->fresh();
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
->and($abo->payone_userid)->toEqual(111)
->and($abo->card_update_pending_at)->toBeNull();
});

View file

@ -0,0 +1,74 @@
<?php
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Models\UserAbo;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
uses(TestCase::class);
function validCardForm(array $override = []): array
{
return array_merge([
'cc_cardholder_first' => 'Max',
'cc_cardholder_last' => 'Mustermann',
'cc_cardpan' => '4111111111111111',
'cc_cardexpiremonth' => '12',
'cc_cardexpireyear' => '2030',
'cc_cardtype' => 'V',
'cc_cardcvc2' => '123',
], $override);
}
it('maps form data to the cc_* keys the payment service expects', function () {
$dto = CreditCardCheckData::fromFormData(validCardForm());
expect($dto)->toBeInstanceOf(CreditCardCheckData::class)
->and($dto->cardPan)->toBe('4111111111111111')
->and($dto->cardType)->toBe('V');
expect($dto->toCheckArray())->toBe([
'cc_cardholder_first' => 'Max',
'cc_cardholder_last' => 'Mustermann',
'cc_cardpan' => '4111111111111111',
'cc_cardexpiremonth' => '12',
'cc_cardexpireyear' => '2030',
'cc_cardtype' => 'V',
'cc_cardcvc2' => '123',
]);
});
it('accepts a valid credit card form', function () {
$rules = (new UpdateAboCreditCardRequest)->rules();
expect(Validator::make(validCardForm(), $rules)->passes())->toBeTrue();
});
it('rejects invalid credit card input', function (array $override) {
$rules = (new UpdateAboCreditCardRequest)->rules();
expect(Validator::make(validCardForm($override), $rules)->fails())->toBeTrue();
})->with([
'missing holder' => [['cc_cardholder_first' => '']],
'non-numeric pan' => [['cc_cardpan' => '4111-1111-1111']],
'too short pan' => [['cc_cardpan' => '411111']],
'invalid month' => [['cc_cardexpiremonth' => '13']],
'two-digit year' => [['cc_cardexpireyear' => '30']],
'unknown card type' => [['cc_cardtype' => 'X']],
'too short cvc' => [['cc_cardcvc2' => '12']],
]);
it('allows updating the card only for active credit-card abos', function () {
expect((new UserAbo(['clearingtype' => 'cc', 'status' => 2]))->canUpdatePaymentMethod())->toBeTrue();
expect((new UserAbo(['clearingtype' => 'cc', 'status' => 3]))->canUpdatePaymentMethod())->toBeTrue(); // hold = Kartenwechsel erlaubt
});
it('forbids updating the card for non-cc or terminal abos', function (string $clearingtype, int $status) {
expect((new UserAbo(['clearingtype' => $clearingtype, 'status' => $status]))->canUpdatePaymentMethod())->toBeFalse();
})->with([
'paypal (wlt)' => ['wlt', 2],
'cancelled' => ['cc', 4],
'finished' => ['cc', 5],
'inactive' => ['cc', 6],
]);

View file

@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Portal\OrderController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('returns a not-found (404) instead of a TypeError for a non-numeric order id', function () {
// Vorher (int $id) warf eine TypeError-500; jetzt sauberer findOrFail-404.
expect(fn () => (new OrderController)->myOrderCreate('not-a-number'))
->toThrow(ModelNotFoundException::class);
});