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:
parent
83ef5f1e52
commit
1108381988
36 changed files with 2366 additions and 26 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
93
app/Http/Controllers/Web/AboCardUpdateController.php
Normal file
93
app/Http/Controllers/Web/AboCardUpdateController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
app/Http/Requests/Abo/UpdateAboCreditCardRequest.php
Normal file
61
app/Http/Requests/Abo/UpdateAboCreditCardRequest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
34
app/Mail/AboCardUpdateLinkMail.php
Normal file
34
app/Mail/AboCardUpdateLinkMail.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
app/Models/AboCardUpdateLink.php
Normal file
82
app/Models/AboCardUpdateLink.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
38
app/Models/UserAboCardChange.php
Normal file
38
app/Models/UserAboCardChange.php
Normal 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');
|
||||
}
|
||||
}
|
||||
131
app/Services/AboPaymentMethodService.php
Normal file
131
app/Services/AboPaymentMethodService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
71
app/Services/Payment/Payone/DTOs/CardUpdateResult.php
Normal file
71
app/Services/Payment/Payone/DTOs/CardUpdateResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
app/Services/Payment/Payone/DTOs/CreditCardCheckData.php
Normal file
63
app/Services/Payment/Payone/DTOs/CreditCardCheckData.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
41
docs/2026-07-01.md
Normal 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 ...
|
||||
|
||||
|
||||
|
|
@ -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">×</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">
|
||||
|
|
|
|||
108
resources/views/emails/abo_card_update_link.blade.php
Normal file
108
resources/views/emails/abo_card_update_link.blade.php
Normal 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>
|
||||
107
resources/views/portal/abo/_payment_method_form.blade.php
Normal file
107
resources/views/portal/abo/_payment_method_form.blade.php
Normal 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>
|
||||
20
resources/views/portal/abo/card_update_done.blade.php
Normal file
20
resources/views/portal/abo/card_update_done.blade.php
Normal 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
|
||||
20
resources/views/portal/abo/card_update_link.blade.php
Normal file
20
resources/views/portal/abo/card_update_link.blade.php
Normal 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
|
||||
12
resources/views/portal/abo/payment_method.blade.php
Normal file
12
resources/views/portal/abo/payment_method.blade.php
Normal 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
|
||||
85
resources/views/portal/layouts/public.blade.php
Normal file
85
resources/views/portal/layouts/public.blade.php
Normal 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"> {{ $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>
|
||||
16
resources/views/user/abo/payment_method.blade.php
Normal file
16
resources/views/user/abo/payment_method.blade.php
Normal 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
|
||||
59
tests/Feature/AboAdminDeleteTest.php
Normal file
59
tests/Feature/AboAdminDeleteTest.php
Normal 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);
|
||||
167
tests/Feature/AboCardUpdateLinkTest.php
Normal file
167
tests/Feature/AboCardUpdateLinkTest.php
Normal 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();
|
||||
});
|
||||
55
tests/Feature/AboCrmPaymentMethodTest.php
Normal file
55
tests/Feature/AboCrmPaymentMethodTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
177
tests/Feature/AboPaymentMethodServiceTest.php
Normal file
177
tests/Feature/AboPaymentMethodServiceTest.php
Normal 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();
|
||||
});
|
||||
127
tests/Feature/AboPortalPaymentMethodTest.php
Normal file
127
tests/Feature/AboPortalPaymentMethodTest.php
Normal 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();
|
||||
});
|
||||
74
tests/Feature/AboUpdateCreditCardRequestTest.php
Normal file
74
tests/Feature/AboUpdateCreditCardRequestTest.php
Normal 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],
|
||||
]);
|
||||
14
tests/Feature/PortalOrderCreateTest.php
Normal file
14
tests/Feature/PortalOrderCreateTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue