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
63c36555a5
commit
671022a28d
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue