Abo-Recovery: fehlgeschlagene Folgezahlung im Checkout nachholen

- Pay-now-Flow: bei fehlgeschlagenem Abo (Status 3) begleicht der Inhaber die
  offene Rechnung im Standard-Checkout mit frei waehlbarer, abo-faehiger Zahlart
  (nur eigenes Abo: Berater me-View + Kundenportal), payNow-Controller + Routen
- AboRecoveryCheckoutService baut die ShoppingInstance aus den Abo-Artikeln;
  neue Spalte shopping_orders.recovery_user_abo_id verknuepft Bestellung<->Abo
- Bei Erfolg: AboHelper::advanceAboAfterRecoveryPayment schreibt das Abo fort
  (Status->2, next_date) und uebernimmt die gewaehlte Zahlart als neues Token
  fuer kuenftige Abbuchungen
- Neue Pay-now-Fehlermail (AboPaymentFailedMail) ersetzt die generische
  Fehlermail im Abo-Cron; Link fuehrt auf die Abo-Detailseite (CRM bzw. Portal)
- Retry-/"extern erledigt"-Buttons nur noch fuer Admins; tote user/portal-
  Retry-Routen entfernt
- Checkout: Recovery-Hinweis statt "Abschluss des Abonnements", Lieferadresse
  vorbelegt, korrekter Zurueck-Link (Abo statt Shop)
- UserAbo::selfServiceDetailUrl zentralisiert die Abo-URL (Mail, Back-Links)
- Test-Command abo:test-recovery; Tests fuer payNow, Advance-Logik, Fehlermail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-07-02 10:38:05 +00:00
parent 78db952130
commit 0660f4e0a0
28 changed files with 1535 additions and 181 deletions

View file

@ -0,0 +1,188 @@
<?php
namespace App\Console\Commands;
use App\Mail\AboPaymentFailedMail;
use App\Models\PaymentTransaction;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Services\AboHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
/**
* Testhilfe für die Abo-Recovery-Zahlung (fehlgeschlagene Folgezahlung → nachträglich bezahlen).
*
* Beispiele:
* php artisan abo:test-recovery --abo=123 --fail # Abo als fehlgeschlagen markieren + Mail
* php artisan abo:test-recovery --abo=123 --email=me@test.de # nur Mail an eigene Adresse
* php artisan abo:test-recovery --abo=123 --advance --no-mail # nur erfolgreiche Recovery simulieren
* php artisan abo:test-recovery --abo=123 --fail --advance # kompletter Durchlauf
*/
class AboTestRecovery extends Command
{
protected $signature = 'abo:test-recovery
{--abo= : Abo-ID (Standard: neuestes Abo)}
{--email= : Empfänger der Test-Mail (Standard: E-Mail des Abos)}
{--fail : Abo vorab auf Status 3 (fehlgeschlagen) + aktiv setzen, damit der Pay-now-Button erscheint}
{--advance : Erfolgreiche Recovery-Zahlung simulieren (Zahlart-Update + next_date), ohne echten Payone-Call}
{--no-mail : Keine Test-Mail senden}
{--force : Sicherheitsabfrage bei --advance überspringen}';
protected $description = 'Testet die Abo-Recovery: verschickt die "Zahlung fehlgeschlagen"-Mail und/oder simuliert eine erfolgreiche Recovery-Zahlung.';
public function handle(): int
{
$abo = $this->resolveAbo();
if (! $abo) {
return self::FAILURE;
}
$this->info('=== Abo-Recovery-Test ===');
$this->showAbo($abo, 'Ausgangszustand');
if ($this->option('fail')) {
$abo->forceFill(['status' => 3, 'active' => true])->save();
$this->warn("Abo #{$abo->id} auf Status 3 (fehlgeschlagen) + aktiv gesetzt.");
}
if (! $this->option('no-mail')) {
$this->sendMail($abo);
}
if ($this->option('advance')) {
$this->simulateRecovery($abo);
}
$this->newLine();
$this->info('Fertig.');
return self::SUCCESS;
}
private function resolveAbo(): ?UserAbo
{
if ($id = $this->option('abo')) {
$abo = UserAbo::find($id);
if (! $abo) {
$this->error("Abo mit ID {$id} nicht gefunden.");
return null;
}
return $abo;
}
$abo = UserAbo::where('status', '>=', 1)->orderByDesc('id')->first();
if (! $abo) {
$this->error('Kein Abo gefunden. Bitte --abo=<ID> angeben.');
return null;
}
$this->line("Kein --abo angegeben, nutze neuestes Abo #{$abo->id}.");
return $abo;
}
private function sendMail(UserAbo $abo): void
{
$recipient = $this->option('email') ?: $abo->email;
if (! $recipient) {
$this->warn('Keine Empfänger-Adresse (Abo ohne E-Mail, kein --email). Mail übersprungen.');
return;
}
$link = (new AboPaymentFailedMail($abo))->build()->viewData['link'] ?? '(kein Link)';
$locale = $abo->shopping_user?->getLocale() ?: config('app.locale');
Mail::to($recipient)->locale($locale)->send(new AboPaymentFailedMail($abo));
$this->newLine();
$this->info('✓ "Zahlung fehlgeschlagen"-Mail versendet');
$this->table(['Feld', 'Wert'], [
['Empfänger', $recipient],
['Sprache', $locale],
['Abo-Typ', $abo->is_for.($abo->is_for === 'me' ? ' (Berater-CRM)' : ' (Kundenportal)')],
['Pay-now-Link', $link],
]);
$this->line('Tipp: Mail im Mailpit prüfen (mivita-mail.test).');
}
private function simulateRecovery(UserAbo $abo): void
{
$this->newLine();
$this->warn('ACHTUNG: --advance überschreibt payone_userid/carddata dieses Abos mit TEST-Werten.');
$this->warn('Das echte Abbuchungs-Token geht dabei verloren – nur auf Wegwerf-/Test-Abos ausführen!');
if (! $this->option('force') && ! $this->confirm("Trotzdem Recovery-Zahlung für Abo #{$abo->id} simulieren?", false)) {
$this->line('Simulation abgebrochen.');
return;
}
$this->warn('Simuliere erfolgreiche Recovery-Zahlung (erzeugt Test-Order/-Payment, KEIN echter Payone-Call)...');
$reference = $abo->shopping_user?->shopping_orders()->orderByDesc('id')->first();
$newUserId = (int) $abo->payone_userid + 1; // sichtbar geändertes Token
$fakeCard = ['cardpan' => '411111XXXXXX'.random_int(1000, 9999), 'cardtype' => 'V', 'cardexpiredate' => '3012'];
$order = ShoppingOrder::create([
'shopping_user_id' => $abo->shopping_user_id ?: 1,
'auth_user_id' => $abo->user_id ?: ($reference->auth_user_id ?? 1),
'country_id' => $reference->country_id ?? 1,
'user_shop_id' => $reference->user_shop_id ?? 1,
'recovery_user_abo_id' => $abo->id,
'is_abo' => true,
'abo_interval' => $abo->abo_interval,
'paid' => true,
'mode' => 'test',
]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'clearingtype' => 'cc',
'wallettype' => null,
'carddata' => $fakeCard,
'reference' => 'recovery-test-'.uniqid(),
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => $abo->abo_interval,
]);
PaymentTransaction::create([
'shopping_payment_id' => $payment->id,
'userid' => $newUserId,
'txid' => 'test-'.uniqid(),
'status' => 'APPROVED',
'txaction' => 'appointed',
'mode' => 'test',
]);
AboHelper::advanceAboAfterRecoveryPayment($payment->fresh());
$this->showAbo($abo->fresh(), 'Nach Recovery-Zahlung');
$this->info('✓ Abo fortgeschrieben (Status→2, next_date gesetzt, Zahlart/Token aktualisiert).');
}
private function showAbo(UserAbo $abo, string $title): void
{
$this->newLine();
$this->line("<info>{$title}</info> — Abo #{$abo->id}");
$this->table(['Feld', 'Wert'], [
['Status', $abo->status.' ('.($abo->getStatusType() ?? '?').')'],
['Aktiv', $abo->active ? 'Ja' : 'Nein'],
['is_for', $abo->is_for],
['E-Mail', $abo->email],
['clearingtype', $abo->clearingtype ?? '-'],
['wallettype', $abo->wallettype ?? '-'],
['payone_userid', $abo->payone_userid],
['carddata (maskiert)', $abo->maskedCard() ?? '-'],
['next_date', $abo->next_date],
['last_date', $abo->last_date],
]);
}
}

View file

@ -3,16 +3,17 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Cron\UserMakeOrder; use App\Cron\UserMakeOrder;
use App\Mail\AboPaymentFailedMail;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Models\UserAboOrder; use App\Models\UserAboOrder;
use App\Services\AboHelper; use App\Services\AboHelper;
use App\Services\AboOneTimeService; use App\Services\AboOneTimeService;
use App\Services\Incentive\IncentiveTracker; use App\Services\Incentive\IncentiveTracker;
use App\Services\MyLog; use App\Services\MyLog;
use App\Services\Payment;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class UserMakeAboOrder extends Command class UserMakeAboOrder extends Command
{ {
@ -282,17 +283,10 @@ class UserMakeAboOrder extends Command
// Damit wird das Abo beim nächsten Cron-Lauf erneut versucht // Damit wird das Abo beim nächsten Cron-Lauf erneut versucht
$this->updateAboOnError($userAbo, $shoppingOrder, 3, $response); $this->updateAboOnError($userAbo, $shoppingOrder, 3, $response);
$shoppingPayment = $userOrder->getShoppingPayment(); // Fehlgeschlagene Folgezahlung: den Inhaber per Pay-now-Mail informieren
if ($shoppingPayment) { // (ersetzt die frühere generische Fehler-Mail). Der Link führt auf die Abo-Detailseite,
$data = [ // wo die Zahlung nachgeholt und die Zahlart für künftige Abbuchungen aktualisiert wird.
'mode' => $shoppingPayment->mode, $this->sendPaymentFailedMail($userAbo);
'txaction' => 'error',
'send_link' => false,
'payment_error' => $response,
];
Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, $data);
}
} elseif ($response['status'] === 'PENDING' || $response['status'] === 'REDIRECT') { } elseif ($response['status'] === 'PENDING' || $response['status'] === 'REDIRECT') {
// Pending/Redirect Status: Bestellung speichern, aber Abo nicht aktualisieren // Pending/Redirect Status: Bestellung speichern, aber Abo nicht aktualisieren
\Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [ \Log::channel('abo_order')->info('UserMakeAboOrder: Zahlung ausstehend/weiterleitung', [
@ -456,6 +450,27 @@ class UserMakeAboOrder extends Command
} }
} }
/**
* Informiert den Abo-Inhaber per E-Mail über die fehlgeschlagene Folgezahlung inkl. Pay-now-Link.
* Fehler beim Versand dürfen den Cron-Lauf nicht abbrechen.
*/
private function sendPaymentFailedMail(UserAbo $userAbo): void
{
if (! $userAbo->email) {
return;
}
try {
$locale = $userAbo->shopping_user?->getLocale() ?: config('app.locale');
Mail::to($userAbo->email)->locale($locale)->send(new AboPaymentFailedMail($userAbo));
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('UserMakeAboOrder: Pay-now-Fehlermail konnte nicht gesendet werden', [
'abo_id' => $userAbo->id,
'error' => $e->getMessage(),
]);
}
}
/** /**
* Berechnet die Ausführungszeit * Berechnet die Ausführungszeit
* *

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\AboOneTimeItemRequest; use App\Http\Requests\Abo\AboOneTimeItemRequest;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Models\Product; use App\Models\Product;
use App\Models\ShoppingInstance; use App\Models\ShoppingInstance;
use App\Models\ShoppingUser; use App\Models\ShoppingUser;
@ -15,7 +16,8 @@ use App\Services\AboHelper;
use App\Services\AboItemHistoryService; use App\Services\AboItemHistoryService;
use App\Services\AboOneTimeService; use App\Services\AboOneTimeService;
use App\Services\AboOrderCart; use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService; use App\Services\AboPaymentMethodService;
use App\Services\AboRecoveryCheckoutService;
use App\Services\Shop; use App\Services\Shop;
use App\Services\UserService; use App\Services\UserService;
use App\Services\Util; use App\Services\Util;
@ -508,16 +510,29 @@ class AboController extends Controller
return false; return false;
} }
public function retryPayment($id, AboRetryPaymentService $retryPaymentService) /**
* Startet den Recovery-Checkout für ein fehlgeschlagenes Abo: der Kunde begleicht die offene
* Folgerechnung seines eigenen Abos über den Checkout mit frei wählbarer (abo-fähiger) Zahlart.
*/
public function payNow($id, AboRecoveryCheckoutService $recoveryCheckout)
{ {
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo); $this->checkPortalPermission($user_abo);
$result = $retryPaymentService->retry($user_abo); if ($user_abo->status !== 3 || ! $user_abo->active) {
\Session()->flash('alert-error', __('abo.pay_now_not_allowed'));
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); return redirect(route('portal.my_subscriptions'));
}
return redirect(route('portal.my_subscriptions')); $url = $recoveryCheckout->buildCheckoutUrl($user_abo);
if (! $url) {
\Session()->flash('alert-error', __('abo.pay_now_failed'));
return redirect(route('portal.my_subscriptions'));
}
return redirect()->away($url);
} }
private function checkPortalPermission($user_abo) private function checkPortalPermission($user_abo)
@ -532,6 +547,75 @@ class AboController extends Controller
} }
} }
public function editPaymentMethod($id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
if (! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('portal.my_subscriptions'));
}
return view('portal.abo.payment_method', ['user_abo' => $user_abo, 'view' => 'portal']);
}
public function updatePaymentMethod(UpdateAboCreditCardRequest $request, $id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($user_abo);
$returnUrls = [
'successurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'success']),
'errorurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'error']),
'backurl' => route('portal_abos_payment_method_return', [$user_abo->id, 'cancel']),
];
$result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'portal');
if ($result->isApproved()) {
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('portal.my_subscriptions'));
}
if ($result->isRedirect()) {
// Karten-Token bis zur 3DS-Rückkehr in der Session zwischenspeichern.
session(['abo_card_update.'.$user_abo->id => [
'carddata' => $result->carddata,
'userid' => $result->userid,
]]);
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.card_update_failed'));
return redirect(route('portal_abos_payment_method_edit', [$user_abo->id]))->withInput();
}
public function paymentMethodReturn($id, $status, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPortalPermission($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), 'portal');
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('portal.my_subscriptions'));
}
// Abbruch oder Fehler an der 3DS-Rückkehr: Pending-Spur zurücknehmen, Karte bleibt unverändert.
$user_abo->forceFill(['card_update_pending_at' => null])->save();
\Session()->flash('alert-error', __('abo.card_update_failed'));
return redirect(route('portal.my_subscriptions'));
}
public function myAboCreate($step) public function myAboCreate($step)
{ {
$user = Auth::guard('customers')->user(); $user = Auth::guard('customers')->user();

View file

@ -4,6 +4,9 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Abo\AboOneTimeItemRequest; use App\Http\Requests\Abo\AboOneTimeItemRequest;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Mail\AboCardUpdateLinkMail;
use App\Models\AboCardUpdateLink;
use App\Models\Product; use App\Models\Product;
use App\Models\ShoppingInstance; use App\Models\ShoppingInstance;
use App\Models\UserAbo; use App\Models\UserAbo;
@ -13,7 +16,8 @@ use App\Repositories\AboRepository;
use App\Services\AboHelper; use App\Services\AboHelper;
use App\Services\AboItemHistoryService; use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart; use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService; use App\Services\AboPaymentMethodService;
use App\Services\AboRecoveryCheckoutService;
use App\Services\OrderPaymentService; use App\Services\OrderPaymentService;
use App\Services\Shop; use App\Services\Shop;
use App\User; use App\User;
@ -543,16 +547,34 @@ class AboController extends Controller
->make(true); ->make(true);
} }
public function retryPayment($view, $id, AboRetryPaymentService $retryPaymentService) /**
* Startet den Recovery-Checkout für ein fehlgeschlagenes Abo: der Berater begleicht die offene
* Folgerechnung seines EIGENEN Abos (view=me) über den Checkout mit frei wählbarer Zahlart.
* Kein Kunden-Abo (ot), kein Admin-Bypass – das übernimmt der Kunde selbst im Portal.
*/
public function payNow($view, $id, AboRecoveryCheckoutService $recoveryCheckout)
{ {
$user_abo = UserAbo::findOrFail($id); $user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo); $this->checkPermissions($view, $user_abo);
$result = $retryPaymentService->retry($user_abo); if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) {
abort(403, 'Pay now is only allowed for the subscription owner.');
}
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); if ($user_abo->status !== 3 || ! $user_abo->active) {
\Session()->flash('alert-error', __('abo.pay_now_not_allowed'));
return redirect(route('user_abos_detail', [$view, $id])); return redirect(route('user_abos_detail', [$view, $id]));
}
$url = $recoveryCheckout->buildCheckoutUrl($user_abo);
if (! $url) {
\Session()->flash('alert-error', __('abo.pay_now_failed'));
return redirect(route('user_abos_detail', [$view, $id]));
}
return redirect()->away($url);
} }
public function deletePending($view, $id) public function deletePending($view, $id)
@ -616,6 +638,111 @@ class AboController extends Controller
->values(); ->values();
} }
public function editPaymentMethod($view, $id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
if (! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
return view('user.abo.payment_method', ['user_abo' => $user_abo, 'view' => $view]);
}
public function updatePaymentMethod(UpdateAboCreditCardRequest $request, $view, $id, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $user_abo);
$returnUrls = [
'successurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'success']),
'errorurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'error']),
'backurl' => route('user_abos_payment_method_return', [$view, $user_abo->id, 'cancel']),
];
$result = $service->updateCreditCard($user_abo, $request->toDto(), $returnUrls, 'crm');
if ($result->isApproved()) {
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
if ($result->isRedirect()) {
session(['abo_card_update.'.$user_abo->id => [
'carddata' => $result->carddata,
'userid' => $result->userid,
]]);
return redirect()->away($result->redirectUrl);
}
\Session()->flash('alert-error', $result->errorMessage ?? __('abo.card_update_failed'));
return redirect(route('user_abos_payment_method_edit', [$view, $user_abo->id]))->withInput();
}
public function paymentMethodReturn($view, $id, $status, AboPaymentMethodService $service)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
$this->assertOwnerCardEntry($view, $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), 'crm');
\Session()->flash('alert-success', __('abo.card_update_success'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
$user_abo->forceFill(['card_update_pending_at' => null])->save();
\Session()->flash('alert-error', __('abo.card_update_failed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
/**
* Berater verschickt einen Kreditkarten-Update-Link an seinen Kunden (Kunden-Abo, view=ot).
* Der Berater gibt die Karte NICHT selbst ein – der Kunde aktualisiert sie login-frei über den Link.
*/
public function sendCardUpdateLink($view, $id)
{
$user_abo = UserAbo::findOrFail($id);
$this->checkPermissions($view, $user_abo);
if ($view !== 'ot' || $user_abo->is_for !== 'ot' || ! $user_abo->isCreditCard() || ! $user_abo->canUpdatePaymentMethod()) {
\Session()->flash('alert-error', __('abo.card_update_link_not_allowed'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
[$link, $token] = AboCardUpdateLink::generateFor($user_abo, (int) \Auth::id());
\Mail::to($user_abo->email)->send(new AboCardUpdateLinkMail($user_abo, $token));
\Session()->flash('alert-success', __('abo.card_update_link_sent'));
return redirect(route('user_abos_detail', [$view, $user_abo->id]));
}
/**
* Karteneingabe ausschließlich durch den Inhaber selbst (Berater-eigenes Abo, view=me).
* Kein Admin-Bypass, keine Kunden-Abos (ot) – diese laufen über den Update-Link.
*/
private function assertOwnerCardEntry($view, UserAbo $user_abo): void
{
if ($view !== 'me' || $user_abo->is_for !== 'me' || (int) $user_abo->user_id !== (int) \Auth::id()) {
abort(403, 'Card entry is only allowed for the subscription owner.');
}
}
private function checkPermissions($view, $user_abo) private function checkPermissions($view, $user_abo)
{ {
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]); \Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);

View file

@ -58,6 +58,7 @@ class CheckoutController extends Controller
$is_for = $shopping_data['is_for'] ?? false; $is_for = $shopping_data['is_for'] ?? false;
$is_abo = isset($shopping_data['is_abo']) ? (bool) $shopping_data['is_abo'] : false; $is_abo = isset($shopping_data['is_abo']) ? (bool) $shopping_data['is_abo'] : false;
$abo_interval = $shopping_data['abo_interval'] ?? 0; $abo_interval = $shopping_data['abo_interval'] ?? 0;
$recovery_user_abo_id = $shopping_data['recovery_user_abo_id'] ?? null;
$homeparty_id = $shopping_data['homeparty_id'] ?? null; $homeparty_id = $shopping_data['homeparty_id'] ?? null;
$shopping_user = null; $shopping_user = null;
@ -88,6 +89,7 @@ class CheckoutController extends Controller
'is_for' => $is_for, 'is_for' => $is_for,
'is_abo' => $is_abo, 'is_abo' => $is_abo,
'abo_interval' => $abo_interval, 'abo_interval' => $abo_interval,
'recovery_user_abo_id' => $recovery_user_abo_id,
'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(), 'customer_order_source_options' => ShoppingOrder::customerOrderSourceOptions(),
'shopping_data' => $shopping_data, 'shopping_data' => $shopping_data,
'user_shop' => Util::getUserShop(), 'user_shop' => Util::getUserShop(),
@ -522,8 +524,10 @@ class CheckoutController extends Controller
$this->checkoutRepo->sessionDestroy(true); $this->checkoutRepo->sessionDestroy(true);
Util::setInstanceStatus(3, true); // link_pending Util::setInstanceStatus(3, true); // link_pending
// Abo erstellen, falls nötig // Abo erstellen bzw. bei Recovery-Zahlung das bestehende Abo fortschreiben
if ($ShoppingPayment->shopping_order->is_abo) { if ($ShoppingPayment->shopping_order->recovery_user_abo_id) {
AboHelper::advanceAboAfterRecoveryPayment($ShoppingPayment);
} elseif ($ShoppingPayment->shopping_order->is_abo) {
AboHelper::createNewAbo($ShoppingPayment); AboHelper::createNewAbo($ShoppingPayment);
} }
@ -534,6 +538,7 @@ class CheckoutController extends Controller
'pay_trans' => $payt, 'pay_trans' => $payt,
'is_checkout' => true, 'is_checkout' => true,
'yard_instance' => $this->instance, 'yard_instance' => $this->instance,
'back_url' => $this->recoveryBackUrl($ShoppingPayment->shopping_order),
]; ];
return view('web.templates.checkout-final', $data); return view('web.templates.checkout-final', $data);
@ -562,8 +567,10 @@ class CheckoutController extends Controller
$this->checkoutRepo->sessionDestroy(true); $this->checkoutRepo->sessionDestroy(true);
Util::setInstanceStatus(3, true); // link_pending Util::setInstanceStatus(3, true); // link_pending
// Abo erstellen, falls nötig // Abo erstellen bzw. bei Recovery-Zahlung das bestehende Abo fortschreiben
if ($payt->shopping_payment->shopping_order->is_abo) { if ($payt->shopping_payment->shopping_order->recovery_user_abo_id) {
AboHelper::advanceAboAfterRecoveryPayment($payt->shopping_payment);
} elseif ($payt->shopping_payment->shopping_order->is_abo) {
AboHelper::createNewAbo($payt->shopping_payment); AboHelper::createNewAbo($payt->shopping_payment);
} }
@ -578,11 +585,25 @@ class CheckoutController extends Controller
'pay_trans' => $payt, 'pay_trans' => $payt,
'is_checkout' => true, 'is_checkout' => true,
'yard_instance' => $this->instance, 'yard_instance' => $this->instance,
'back_url' => $this->recoveryBackUrl($payt->shopping_payment->shopping_order),
]; ];
return view('web.templates.checkout-final', $data); return view('web.templates.checkout-final', $data);
} }
/**
* Back-Link auf der Bestätigungsseite: bei einer Recovery-Zahlung zurück auf die Abo-Detailseite
* (Berater-CRM bzw. Kundenportal), sonst null → das Blade fällt auf den Shop-Back-Link zurück.
*/
private function recoveryBackUrl($shoppingOrder): ?string
{
if (! $shoppingOrder || ! $shoppingOrder->recovery_user_abo_id) {
return null;
}
return \App\Models\UserAbo::find($shoppingOrder->recovery_user_abo_id)?->selfServiceDetailUrl();
}
/** /**
* Speichert die Zahlungsdaten des Benutzers * Speichert die Zahlungsdaten des Benutzers
* *

View file

@ -0,0 +1,38 @@
<?php
namespace App\Mail;
use App\Models\UserAbo;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
/**
* Wird versendet, wenn eine Abo-Folgezahlung fehlschlägt (Abo geht auf Status 3).
* Enthält einen Link auf die Abo-Detailseite (Berater-CRM bei is_for=me, sonst Kundenportal),
* wo der Inhaber die offene Zahlung nachholen und damit die Zahlart für künftige Abbuchungen
* aktualisieren kann.
*/
class AboPaymentFailedMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public UserAbo $userAbo) {}
public function build(): self
{
$firstname = $this->userAbo->shopping_user?->billing_firstname;
$salutation = $firstname ? __('email.hello').' '.$firstname : __('email.hello');
$link = $this->userAbo->selfServiceDetailUrl();
return $this->subject(__('abo.payment_failed_mail_subject'))
->view('emails.abo_payment_failed')
->with([
'salutation' => $salutation,
'link' => $link,
'greetings' => __('email.greetings'),
'sender' => __('email.sender'),
]);
}
}

View file

@ -162,6 +162,7 @@ class ShoppingOrder extends Model
'paid', 'paid',
'is_abo', 'is_abo',
'abo_interval', 'abo_interval',
'recovery_user_abo_id',
'txaction', 'txaction',
'wp_invoice_path', 'wp_invoice_path',
'api_notice', 'api_notice',
@ -179,6 +180,7 @@ class ShoppingOrder extends Model
'net_split' => 'array', 'net_split' => 'array',
'abo_interval' => 'int', 'abo_interval' => 'int',
'is_abo' => 'bool', 'is_abo' => 'bool',
'recovery_user_abo_id' => 'int',
'points' => 'float', 'points' => 'float',
]; ];

View file

@ -102,6 +102,7 @@ class UserAbo extends Model
'cancel_date' => 'datetime', 'cancel_date' => 'datetime',
'user_deleted_at' => 'datetime', 'user_deleted_at' => 'datetime',
'carddata' => 'array', 'carddata' => 'array',
'card_update_pending_at' => 'datetime',
]; ];
@ -125,6 +126,7 @@ class UserAbo extends Model
'onetime_reminder_sent_for', 'onetime_reminder_sent_for',
'cancel_date', 'cancel_date',
'user_deleted_at', 'user_deleted_at',
'card_update_pending_at',
]; ];
public static $aboDeliveryDays = [5, 10, 20, 25]; public static $aboDeliveryDays = [5, 10, 20, 25];
@ -250,6 +252,73 @@ class UserAbo extends Model
return $this->isPendingInitialPayment() && $this->hasFailedInitialPayment(); return $this->isPendingInitialPayment() && $this->hasFailedInitialPayment();
} }
public function isCreditCard(): bool
{
return $this->clearingtype === 'cc';
}
/**
* Selbstbedienungs-Detailseite dieses Abos: Berater-eigenes Abo (is_for=me) → CRM,
* Kunden-Abo → Kundenportal. Genutzt für Recovery-Links (Mail, Checkout-Zurück).
*/
public function selfServiceDetailUrl(): string
{
return $this->is_for === 'me'
? rtrim(Util::getMyMivitaUrl(), '/').'/user/abos/detail/me/'.$this->id
: rtrim(Util::getMyMivitaPortalUrl(), '/').'/portal/my-subscriptions';
}
/**
* Maskierte Kartendarstellung für die Anzeige, z. B. „411111XXXXXX1111 (12/30)".
* Nutzt ausschließlich Token-/Maskendaten aus carddata (nie PAN/CVC).
*/
public function maskedCard(): ?string
{
if (! $this->isCreditCard()) {
return null;
}
return self::formatMaskedCard(is_array($this->carddata) ? $this->carddata : null);
}
/**
* Baut die maskierte Kartendarstellung aus beliebigen carddata (Token-/Maskendaten).
*
* @param array<string, mixed>|null $carddata
*/
public static function formatMaskedCard(?array $carddata): ?string
{
$pan = $carddata['truncatedcardpan'] ?? null;
if (! $pan) {
return null;
}
$expire = (string) ($carddata['cardexpiredate'] ?? '');
if (strlen($expire) === 4) {
return $pan.' ('.substr($expire, 2, 2).'/'.substr($expire, 0, 2).')';
}
return $pan;
}
public function card_changes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(UserAboCardChange::class)->latest();
}
/**
* Ob der Kreditkarten-Token dieses Abos aktualisiert werden darf. Guard-agnostische
* Invariante: nur CC-Abos, nicht gelöscht, kein terminaler/inaktiver Status
* (4 = storniert, 5 = beendet, 6 = inaktiv). Die Eigentümer-/Guard-Prüfung erfolgt
* zusätzlich im jeweiligen Controller (CRM bzw. Portal).
*/
public function canUpdatePaymentMethod(): bool
{
return $this->isCreditCard()
&& ! $this->trashed()
&& ! in_array((int) $this->status, [4, 5, 6], true);
}
public function user_abo_items() public function user_abo_items()
{ {
return $this->hasMany(UserAboItem::class); return $this->hasMany(UserAboItem::class);

View file

@ -101,6 +101,7 @@ class CheckoutRepository extends BaseRepository
'weight' => Yard::instance($this->instance)->weight(), 'weight' => Yard::instance($this->instance)->weight(),
'is_abo' => isset($data['is_abo']) ? $data['is_abo'] : false, 'is_abo' => isset($data['is_abo']) ? $data['is_abo'] : false,
'abo_interval' => isset($data['abo_interval']) ? $data['abo_interval'] : null, 'abo_interval' => isset($data['abo_interval']) ? $data['abo_interval'] : null,
'recovery_user_abo_id' => ! empty($data['recovery_user_abo_id']) ? (int) $data['recovery_user_abo_id'] : null,
'txaction' => 'prev', 'txaction' => 'prev',
'mode' => Util::getUserShoppingMode(), 'mode' => Util::getUserShoppingMode(),
]; ];

View file

@ -227,13 +227,19 @@ class AboHelper
} }
/** /**
* Sichtbarkeit des Einmalprodukte-Features. Maßgeblich ist allein das * Sichtbarkeit des Einmalprodukte-Features:
* konfigurierte Zeitfenster vor der Ausführung (Sales Center und Portal * - VIP-User (Admin) sehen/nutzen das Feature dauerhaft, unabhängig vom Zeitfenster.
* gleichermaßen). Das frühere Review-Gate (nur VIP, Portal ausgeblendet) * - Alle anderen (normale Berater im Sales Center sowie Endkunden im Portal, die nie
* wurde nach Abschluss der Abstimmung aufgehoben. * auf dem `user`-Guard angemeldet sind) nur innerhalb des konfigurierten Zeitfensters
* vor der Ausführung ({@see isOneTimeWindowOpen}, Setting `abo-onetime-window-days`).
*/ */
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
{ {
$user = \Auth::guard('user')->user();
if ($user instanceof User && $user->isVIP()) {
return true;
}
return self::isOneTimeWindowOpen($userAbo); return self::isOneTimeWindowOpen($userAbo);
} }
@ -348,6 +354,80 @@ class AboHelper
} }
} }
/**
* Schreibt ein bestehendes Abo nach einer erfolgreichen Recovery-Zahlung fort
* (der Inhaber hat die offene Folgerechnung über den Checkout mit freier Zahlart beglichen).
* Es wird KEIN neues Abo angelegt – nur das vorhandene Abo reaktiviert und der nächste Termin gesetzt.
*/
public static function advanceAboAfterRecoveryPayment(ShoppingPayment $shopping_payment): void
{
$order = $shopping_payment->shopping_order;
if (! $order || ! $order->recovery_user_abo_id) {
return;
}
$userAbo = UserAbo::find($order->recovery_user_abo_id);
if (! $userAbo) {
return;
}
// Bereits verknüpft (z. B. Payone-Status-URL vor Checkout-Redirect) oder wiederholter Aufruf
if (UserAboOrder::where('shopping_order_id', $order->id)->exists()) {
return;
}
$aboInterval = (int) ($userAbo->abo_interval ?: $order->abo_interval);
// Die im Recovery-Checkout gewählte Zahlart wird als neues Abo-Zahlungsmittel übernommen,
// damit auch künftige Folgeabbuchungen darüber laufen (das alte Token war i. d. R. defekt).
$paymentTransaction = $shopping_payment->payment_transactions->last();
$payoneUserId = $paymentTransaction && (int) $paymentTransaction->userid > 0
? (int) $paymentTransaction->userid
: (int) $userAbo->payone_userid;
\DB::transaction(function () use ($userAbo, $order, $aboInterval, $shopping_payment, $payoneUserId): void {
$userAbo->update([
'status' => 2,
'active' => true,
'clearingtype' => $shopping_payment->clearingtype,
'wallettype' => $shopping_payment->wallettype,
'carddata' => $shopping_payment->carddata,
'payone_userid' => $payoneUserId,
'next_date' => $aboInterval > 0 ? self::setNextDate(now(), $aboInterval) : $userAbo->next_date,
'last_date' => now(),
]);
UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 1,
'paid' => true,
]);
if (! empty($shopping_payment->carddata)) {
$userAbo->card_changes()->create([
'masked_card' => UserAbo::formatMaskedCard($shopping_payment->carddata),
'source' => 'recovery-checkout',
]);
}
});
try {
$order->refresh();
if ($order->paid) {
IncentiveTracker::trackAboActivated($order);
}
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboHelper::advanceAboAfterRecoveryPayment: Incentive-Tracking fehlgeschlagen', [
'abo_id' => $userAbo->id,
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
AboOneTimeService::purgeAfterExecution($userAbo);
}
public static function createAboItems($user_abo, ShoppingPayment $shopping_payment) public static function createAboItems($user_abo, ShoppingPayment $shopping_payment)
{ {
foreach ($shopping_payment->shopping_order->shopping_order_items as $item) { foreach ($shopping_payment->shopping_order->shopping_order_items as $item) {

View file

@ -0,0 +1,155 @@
<?php
namespace App\Services;
use App\Models\ShoppingInstance;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use Illuminate\Support\Facades\Log;
use Yard;
class AboRecoveryCheckoutService
{
/**
* Baut für ein fehlgeschlagenes (Status 3, aktives) Abo einen Recovery-Checkout auf:
* Warenkorb aus den aktuellen Abo-Artikeln, eine ShoppingInstance und die Checkout-URL,
* über die der Inhaber die offene Folgerechnung mit freier (abo-fähiger) Zahlart begleicht.
*
* Die Attribution wird durch Nachbilden der bewährten Checkout-Pfade korrekt gehalten:
* - Berater-eigenes Abo (is_for=me) → is_from=user_order, is_for=abo-me (payment=2, auth_user)
* - Kunden-Abo (is_for=ot) → is_from=shopping, is_for=abo-ot-customer (payment=6)
*
* @return string|null Checkout-URL oder null, wenn der Aufbau fehlschlug.
*/
public function buildCheckoutUrl(UserAbo $userAbo): ?string
{
AboHelper::ensureUserAboItemsFromLatestOrder($userAbo);
Yard::instance(AboOrderCart::INSTANCE)->destroy();
AboOrderCart::initYard($userAbo);
AboOrderCart::makeOrderYard($userAbo);
$yard = Yard::instance(AboOrderCart::INSTANCE);
if ($yard->content()->isEmpty()) {
Log::channel('abo_order')->error('AboRecoveryCheckoutService: Leerer Warenkorb, Abbruch', ['abo_id' => $userAbo->id]);
return null;
}
$shoppingUser = $userAbo->shopping_user;
$referenceOrder = $shoppingUser?->shopping_orders()->orderByDesc('id')->first();
$isMe = $userAbo->is_for === 'me';
$consultant = $isMe ? $userAbo->user : $userAbo->member;
$consultantShop = $consultant?->shop;
$userShopId = $referenceOrder?->user_shop_id ?? $consultantShop?->id;
if (! $userShopId) {
Log::channel('abo_order')->error('AboRecoveryCheckoutService: Kein user_shop_id ermittelbar', ['abo_id' => $userAbo->id]);
return null;
}
$identifier = $this->makeIdentifier();
ShoppingInstance::create([
'identifier' => $identifier,
'user_shop_id' => $userShopId,
'auth_user_id' => $isMe ? $userAbo->user_id : null,
'payment' => $isMe ? 2 : 6,
'subdomain' => $consultantShop?->getSubdomain(false) ?? url('/'),
'country_id' => $yard->getShippingCountryId() ?? $referenceOrder?->country_id,
'language' => $shoppingUser?->getLocale() ?? \App::getLocale(),
'amount' => $yard->totalWithShipping(2, '.', ''),
'status' => 0,
'shopping_user_id' => $userAbo->shopping_user_id,
'shopping_data' => $this->buildShoppingData($userAbo, $shoppingUser, $yard, $isMe),
'back' => $userAbo->selfServiceDetailUrl(),
]);
$yard->store($identifier);
return route('checkout.checkout_card', ['identifier' => $identifier]);
}
private function makeIdentifier(): string
{
do {
$identifier = Util::getToken();
} while (ShoppingInstance::where('identifier', $identifier)->exists());
return $identifier;
}
/**
* @return array<string, mixed>
*/
private function buildShoppingData(UserAbo $userAbo, ?ShoppingUser $shoppingUser, mixed $yard, bool $isMe): array
{
$data = [
'is_from' => $isMe ? 'user_order' : 'shopping',
'is_for' => $isMe ? 'abo-me' : 'abo-ot-customer',
'is_abo' => true,
'abo_interval' => (int) $userAbo->abo_interval,
'recovery_user_abo_id' => $userAbo->id,
'shopping_user_id' => $userAbo->shopping_user_id,
'user_price_infos' => $yard->getUserPriceInfos(),
'mode' => config('app.mode') === 'test' ? 'test' : 'live',
];
if ($shoppingUser) {
// Lieferadresse immer vorbelegen (mit Rechnungsadresse als Fallback), damit der Checkout
// keine erneute Eingabe verlangt: der me-Pfad liest shipping_* aus shopping_data
// (shoppingUserAuthData erzwingt same_as_billing=true), der ot-Pfad füllt den ShoppingUser
// komplett aus shopping_data (makeCustomerShoppingUser).
$data += $this->shippingFrom($shoppingUser);
if (! $isMe) {
$data += $this->billingFrom($shoppingUser);
$data['member_id'] = $userAbo->member_id;
$data['same_as_billing'] = $shoppingUser->same_as_billing;
}
}
return $data;
}
/**
* @return array<string, mixed>
*/
private function shippingFrom(ShoppingUser $u): array
{
return [
'shipping_salutation' => $u->shipping_salutation ?: $u->billing_salutation,
'shipping_company' => $u->shipping_company ?: $u->billing_company,
'shipping_firstname' => $u->shipping_firstname ?: $u->billing_firstname,
'shipping_lastname' => $u->shipping_lastname ?: $u->billing_lastname,
'shipping_address' => $u->shipping_address ?: $u->billing_address,
'shipping_address_2' => $u->shipping_address_2 ?: $u->billing_address_2,
'shipping_zipcode' => $u->shipping_zipcode ?: $u->billing_zipcode,
'shipping_city' => $u->shipping_city ?: $u->billing_city,
'shipping_phone' => $u->shipping_phone ?: $u->billing_phone,
'shipping_postnumber' => $u->shipping_postnumber,
];
}
/**
* @return array<string, mixed>
*/
private function billingFrom(ShoppingUser $u): array
{
return [
'billing_salutation' => $u->billing_salutation,
'billing_company' => $u->billing_company,
'billing_firstname' => $u->billing_firstname,
'billing_lastname' => $u->billing_lastname,
'billing_address' => $u->billing_address,
'billing_address_2' => $u->billing_address_2,
'billing_zipcode' => $u->billing_zipcode,
'billing_city' => $u->billing_city,
'billing_country_id' => $u->billing_country_id,
'billing_phone' => $u->billing_phone,
'billing_email' => $u->billing_email,
];
}
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->unsignedBigInteger('recovery_user_abo_id')->nullable()->after('abo_interval');
$table->index('recovery_user_abo_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shopping_orders', function (Blueprint $table) {
$table->dropIndex(['recovery_user_abo_id']);
$table->dropColumn('recovery_user_abo_id');
});
}
};

View file

@ -15,6 +15,7 @@ return [
'abo_order_info_check' => 'Mit Abschluss des Abonnements wird eine regelmäßige Lieferung eingerichtet. Diese wird automatisch am gewählten Liefertag versendet und abgerechnet.', 'abo_order_info_check' => 'Mit Abschluss des Abonnements wird eine regelmäßige Lieferung eingerichtet. Diese wird automatisch am gewählten Liefertag versendet und abgerechnet.',
'abo_order_info_check_2' => 'Die erste Lieferung und Abrechnung erfolgt am Tag der Abo-Einrichtung. Danach erfolgt der Versand automatisch am gewählten Liefertag des Folgemonats.', 'abo_order_info_check_2' => 'Die erste Lieferung und Abrechnung erfolgt am Tag der Abo-Einrichtung. Danach erfolgt der Versand automatisch am gewählten Liefertag des Folgemonats.',
'abo_order_info_check_3' => 'Als Zahlungsmethoden stehen PayPal und Kreditkarte zur Verfügung. <strong>Das Abo hat eine Mindestlaufzeit von :abo-min-duration Monaten.</strong> Danach kann es jederzeit pausiert, geändert oder gekündigt werden.', 'abo_order_info_check_3' => 'Als Zahlungsmethoden stehen PayPal und Kreditkarte zur Verfügung. <strong>Das Abo hat eine Mindestlaufzeit von :abo-min-duration Monaten.</strong> Danach kann es jederzeit pausiert, geändert oder gekündigt werden.',
'abo_recovery_info_check' => 'Du begleichst hier die offene Rechnung deines bestehenden Abos. <strong>Die dabei gewählte Zahlart wird zusätzlich für alle zukünftigen Abbuchungen deines Abos gespeichert.</strong> Nach erfolgreicher Zahlung wird dein Abo wieder aktiviert und regelmäßig am gewählten Liefertag ausgeführt.',
'abo_order_info_checkbox' => 'Ja, ich habe die Abo-Bedingungen verstanden!', 'abo_order_info_checkbox' => 'Ja, ich habe die Abo-Bedingungen verstanden!',
'abo_order_info_checkbox_required' => 'Bitte bestätige die Abo-Bedingungen, um fortzufahren.', 'abo_order_info_checkbox_required' => 'Bitte bestätige die Abo-Bedingungen, um fortzufahren.',
'abo_infos' => 'Abo Infos', 'abo_infos' => 'Abo Infos',
@ -172,6 +173,16 @@ return [
'retry_payment_confirm_copy' => 'Der Zahlungsversuch wird sofort gestartet und kann eine PayPal- oder Kreditkartenbuchung auslösen. Bitte nur ausführen, wenn die Ursache geprüft wurde.', 'retry_payment_confirm_copy' => 'Der Zahlungsversuch wird sofort gestartet und kann eine PayPal- oder Kreditkartenbuchung auslösen. Bitte nur ausführen, wenn die Ursache geprüft wurde.',
'retry_payment_confirm_button' => 'Zahlung jetzt erneut versuchen', 'retry_payment_confirm_button' => 'Zahlung jetzt erneut versuchen',
'retry_only_hold' => 'Der erneute Zahlungsversuch ist nur für angehaltene Abos möglich.', 'retry_only_hold' => 'Der erneute Zahlungsversuch ist nur für angehaltene Abos möglich.',
'pay_now_title' => 'Zahlung fehlgeschlagen – jetzt bezahlen',
'pay_now_copy' => 'Die letzte Abbuchung für dein Abo ist fehlgeschlagen (z. B. Karte oder PayPal nicht mehr gültig). Im Checkout begleichst du jetzt die offene Rechnung mit einer Zahlart deiner Wahl – diese wird zusätzlich für alle zukünftigen Abbuchungen deines Abos gespeichert.',
'pay_now_button' => 'Jetzt bezahlen',
'pay_now_not_allowed' => 'Eine nachträgliche Zahlung ist für dieses Abo aktuell nicht möglich.',
'pay_now_failed' => 'Der Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.',
'back_to_abo' => 'Zurück zu meinem Abo',
'payment_failed_mail_subject' => 'Zahlung für dein Abo fehlgeschlagen',
'payment_failed_mail_intro' => 'die letzte Abbuchung für dein Abo ist leider fehlgeschlagen (z. B. weil deine Kreditkarte oder dein PayPal-Konto nicht mehr gültig ist). Über den folgenden Link kannst du den offenen Betrag jetzt sicher bezahlen. Die dabei gewählte Zahlart wird auch für deine künftigen Abbuchungen gespeichert.',
'payment_failed_mail_button' => 'Jetzt bezahlen',
'payment_failed_mail_hint' => 'Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:',
'retry_only_active' => 'Der erneute Zahlungsversuch ist nur für aktive Abos möglich.', 'retry_only_active' => 'Der erneute Zahlungsversuch ist nur für aktive Abos möglich.',
'retry_already_paid_today' => 'Für dieses Abo wurde heute bereits eine erfolgreiche Zahlung gespeichert.', 'retry_already_paid_today' => 'Für dieses Abo wurde heute bereits eine erfolgreiche Zahlung gespeichert.',
'retry_error_shopping_user' => 'Der Shopping-User konnte für den erneuten Zahlungsversuch nicht erstellt werden.', 'retry_error_shopping_user' => 'Der Shopping-User konnte für den erneuten Zahlungsversuch nicht erstellt werden.',
@ -189,6 +200,11 @@ return [
'extern_done_already_paid' => 'Diese Bestellung ist bereits bezahlt und kann nicht extern erledigt werden.', 'extern_done_already_paid' => 'Diese Bestellung ist bereits bezahlt und kann nicht extern erledigt werden.',
'extern_done_success' => 'Die Bestellung wurde als extern erledigt markiert. Punkte gutgeschrieben, Blockade aufgehoben.', 'extern_done_success' => 'Die Bestellung wurde als extern erledigt markiert. Punkte gutgeschrieben, Blockade aufgehoben.',
'extern_done_error' => 'Die Bestellung konnte nicht als erledigt markiert werden: :error', 'extern_done_error' => 'Die Bestellung konnte nicht als erledigt markiert werden: :error',
'admin_delete' => 'Abo löschen',
'admin_delete_confirm_title' => 'Abo löschen',
'admin_delete_confirm_copy' => 'Das Abo wird gelöscht (deleted_at) und ist anschließend nicht mehr in der Übersicht sichtbar. Bitte nur ausführen, wenn dies wirklich gewünscht ist.',
'admin_delete_confirm_button' => 'Abo jetzt löschen',
'admin_delete_success' => 'Das Abo wurde gelöscht.',
'team_subscriptions' => 'Team Abos', 'team_subscriptions' => 'Team Abos',
'team_customer_abos' => 'Team Kunden-Abos', 'team_customer_abos' => 'Team Kunden-Abos',
'team_total' => 'Gesamt', 'team_total' => 'Gesamt',
@ -231,4 +247,35 @@ return [
'customer_privacy_info' => 'Aus Datenschutzgründen werden keine persönlichen Kundendaten angezeigt.', 'customer_privacy_info' => 'Aus Datenschutzgründen werden keine persönlichen Kundendaten angezeigt.',
'every_month_on' => 'monatlich am :day.', 'every_month_on' => 'monatlich am :day.',
'back' => 'zurück', 'back' => 'zurück',
// Kreditkarte aktualisieren
'payment_method' => 'Zahlungsmethode',
'update_payment_method' => 'Zahlungsmethode aktualisieren',
'update_card_title' => 'Kreditkarte aktualisieren',
'current_card' => 'Aktuelle Karte',
'card_update_intro' => 'Hinterlegen Sie eine neue Kreditkarte für dieses Abo. Zur Verifizierung kann eine Autorisierung von 0,01 EUR erfolgen, die nicht belastet wird. Die nächste Abbuchung erfolgt über die neue Karte.',
'card_security_notice' => 'Ihre Kreditkartendaten werden hier nicht gespeichert. Sie werden ausschließlich SSL-verschlüsselt direkt an unseren Zahlungsdienstleister PAYONE zur Verifizierung übermittelt.',
'card_change_history' => 'Änderungsverlauf',
'card_update_link_button' => 'Update-Link an Kunden senden',
'card_update_link_sent' => 'Der Update-Link wurde an den Kunden gesendet.',
'card_update_link_not_allowed' => 'Für dieses Abo kann kein Update-Link gesendet werden.',
'card_update_link_invalid' => 'Dieser Link ist ungültig oder abgelaufen.',
'card_update_link_mail_subject' => 'Kreditkarte für Ihr Abo aktualisieren',
'card_update_link_mail_intro' => 'Bitte aktualisieren Sie die Kreditkarte für Ihr Abo über den folgenden sicheren Link. Ihre Kartendaten werden ausschließlich SSL-verschlüsselt an unseren Zahlungsdienstleister PAYONE übermittelt.',
'card_update_link_mail_button' => 'Kreditkarte aktualisieren',
'card_update_link_mail_hint' => 'Falls der Button nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:',
'card_update_done_success_title' => 'Kreditkarte aktualisiert',
'card_update_done_success_copy' => 'Vielen Dank. Die nächste Abbuchung erfolgt über Ihre neue Karte.',
'card_update_done_failed_title' => 'Aktualisierung fehlgeschlagen',
'card_update_done_failed_copy' => 'Die Kreditkarte konnte nicht aktualisiert werden. Bitte fordern Sie bei Bedarf einen neuen Link an.',
'card_update_portal_hint' => 'Dieses Abo finden Sie auch in Ihrem Kundenportal. Dort können Sie sich anmelden und Ihre Zahlungsmethode sowie Ihr Abo jederzeit selbst verwalten.',
'card_update_to_portal' => 'Zum Kundenportal',
'card_update_success' => 'Ihre Kreditkarte wurde aktualisiert. Die nächste Abbuchung erfolgt über die neue Karte.',
'card_update_not_allowed' => 'Für dieses Abo kann die Kreditkarte nicht aktualisiert werden.',
'card_update_failed' => 'Die Kreditkarte konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.',
'card_holder_required' => 'Bitte geben Sie den Namen des Karteninhabers an.',
'card_number_invalid' => 'Bitte geben Sie eine gültige Kreditkartennummer ein.',
'card_expiry_invalid' => 'Bitte geben Sie ein gültiges Ablaufdatum an.',
'card_type_invalid' => 'Bitte wählen Sie einen gültigen Kartentyp (Visa oder Mastercard).',
'card_cvc_invalid' => 'Bitte geben Sie eine gültige Prüfziffer (CVC) ein.',
]; ];

View file

@ -15,6 +15,7 @@ return [
'abo_order_info_check' => 'By completing the subscription, a regular delivery is set up. This is automatically shipped and billed on the selected delivery day.', 'abo_order_info_check' => 'By completing the subscription, a regular delivery is set up. This is automatically shipped and billed on the selected delivery day.',
'abo_order_info_check_2' => 'The first delivery and billing takes place on the day the subscription is set up. After that, shipping is automatically carried out on the selected delivery day of the following month.', 'abo_order_info_check_2' => 'The first delivery and billing takes place on the day the subscription is set up. After that, shipping is automatically carried out on the selected delivery day of the following month.',
'abo_order_info_check_3' => 'PayPal and credit card are available as payment methods. <strong>The subscription has a minimum duration of :abo-min-duration months.</strong> After that, it can be paused, changed or canceled at any time.', 'abo_order_info_check_3' => 'PayPal and credit card are available as payment methods. <strong>The subscription has a minimum duration of :abo-min-duration months.</strong> After that, it can be paused, changed or canceled at any time.',
'abo_recovery_info_check' => 'You are settling the outstanding invoice of your existing subscription here. <strong>The payment method you choose will also be saved for all future charges of your subscription.</strong> After a successful payment your subscription is reactivated and runs regularly on the selected delivery day.',
'abo_order_info_checkbox' => 'Yes, I have understood the subscription terms!', 'abo_order_info_checkbox' => 'Yes, I have understood the subscription terms!',
'abo_order_info_checkbox_required' => 'Please confirm the subscription terms to continue.', 'abo_order_info_checkbox_required' => 'Please confirm the subscription terms to continue.',
'abo_infos' => 'Subscription info', 'abo_infos' => 'Subscription info',
@ -172,6 +173,16 @@ return [
'retry_payment_confirm_copy' => 'The payment attempt will start immediately and can trigger a PayPal or credit card charge. Please run this only after checking the cause.', 'retry_payment_confirm_copy' => 'The payment attempt will start immediately and can trigger a PayPal or credit card charge. Please run this only after checking the cause.',
'retry_payment_confirm_button' => 'Retry payment now', 'retry_payment_confirm_button' => 'Retry payment now',
'retry_only_hold' => 'The payment retry is only available for held subscriptions.', 'retry_only_hold' => 'The payment retry is only available for held subscriptions.',
'pay_now_title' => 'Payment failed – pay now',
'pay_now_copy' => 'The last charge for your subscription failed (e.g. card or PayPal no longer valid). In the checkout you now settle the outstanding invoice with a payment method of your choice — which will also be saved for all future charges of your subscription.',
'pay_now_button' => 'Pay now',
'pay_now_not_allowed' => 'A follow-up payment is currently not possible for this subscription.',
'pay_now_failed' => 'The checkout could not be started. Please try again later.',
'back_to_abo' => 'Back to my subscription',
'payment_failed_mail_subject' => 'Payment for your subscription failed',
'payment_failed_mail_intro' => 'unfortunately the last charge for your subscription failed (e.g. because your credit card or PayPal account is no longer valid). Using the link below you can pay the outstanding amount now. The payment method you choose will also be saved for your future charges.',
'payment_failed_mail_button' => 'Pay now',
'payment_failed_mail_hint' => 'If the button does not work, copy this link into your browser:',
'retry_only_active' => 'The payment retry is only available for active subscriptions.', 'retry_only_active' => 'The payment retry is only available for active subscriptions.',
'retry_already_paid_today' => 'A successful payment has already been recorded for this subscription today.', 'retry_already_paid_today' => 'A successful payment has already been recorded for this subscription today.',
'retry_error_shopping_user' => 'The shopping user could not be created for the payment retry.', 'retry_error_shopping_user' => 'The shopping user could not be created for the payment retry.',
@ -189,6 +200,11 @@ return [
'extern_done_already_paid' => 'This order is already paid and cannot be completed externally.', 'extern_done_already_paid' => 'This order is already paid and cannot be completed externally.',
'extern_done_success' => 'The order was marked as externally completed. Points credited, block lifted.', 'extern_done_success' => 'The order was marked as externally completed. Points credited, block lifted.',
'extern_done_error' => 'The order could not be marked as completed: :error', 'extern_done_error' => 'The order could not be marked as completed: :error',
'admin_delete' => 'Delete subscription',
'admin_delete_confirm_title' => 'Delete subscription',
'admin_delete_confirm_copy' => 'The subscription will be deleted (deleted_at) and will no longer appear in the overview. Please only proceed if this is really intended.',
'admin_delete_confirm_button' => 'Delete subscription now',
'admin_delete_success' => 'The subscription has been deleted.',
'back' => 'back', 'back' => 'back',
'team_subscriptions' => 'Team subscriptions', 'team_subscriptions' => 'Team subscriptions',
'team_customer_abos' => 'Team Customer Subscriptions', 'team_customer_abos' => 'Team Customer Subscriptions',
@ -231,4 +247,35 @@ return [
'abo_count' => 'Number of subscriptions', 'abo_count' => 'Number of subscriptions',
'customer_privacy_info' => 'For privacy reasons, no personal customer data is displayed.', 'customer_privacy_info' => 'For privacy reasons, no personal customer data is displayed.',
'every_month_on' => 'monthly on :day.', 'every_month_on' => 'monthly on :day.',
// Update credit card
'payment_method' => 'Payment method',
'update_payment_method' => 'Update payment method',
'update_card_title' => 'Update credit card',
'current_card' => 'Current card',
'card_update_intro' => 'Store a new credit card for this subscription. A verification authorization of EUR 0.01 may be made, which is not charged. The next debit will use the new card.',
'card_security_notice' => 'Your credit card details are not stored here. They are transmitted SSL-encrypted directly to our payment provider PAYONE for verification only.',
'card_change_history' => 'Change history',
'card_update_link_button' => 'Send update link to customer',
'card_update_link_sent' => 'The update link has been sent to the customer.',
'card_update_link_not_allowed' => 'No update link can be sent for this subscription.',
'card_update_link_invalid' => 'This link is invalid or has expired.',
'card_update_link_mail_subject' => 'Update the credit card for your subscription',
'card_update_link_mail_intro' => 'Please update the credit card for your subscription via the secure link below. Your card details are transmitted SSL-encrypted to our payment provider PAYONE only.',
'card_update_link_mail_button' => 'Update credit card',
'card_update_link_mail_hint' => 'If the button does not work, copy this link into your browser:',
'card_update_done_success_title' => 'Credit card updated',
'card_update_done_success_copy' => 'Thank you. The next debit will use your new card.',
'card_update_done_failed_title' => 'Update failed',
'card_update_done_failed_copy' => 'The credit card could not be updated. Please request a new link if needed.',
'card_update_portal_hint' => 'You can also find this subscription in your customer portal. There you can log in and manage your payment method and subscription yourself at any time.',
'card_update_to_portal' => 'Go to customer portal',
'card_update_success' => 'Your credit card has been updated. The next debit will use the new card.',
'card_update_not_allowed' => 'The credit card cannot be updated for this subscription.',
'card_update_failed' => 'The credit card could not be updated. Please try again.',
'card_holder_required' => 'Please enter the cardholder name.',
'card_number_invalid' => 'Please enter a valid credit card number.',
'card_expiry_invalid' => 'Please enter a valid expiry date.',
'card_type_invalid' => 'Please select a valid card type (Visa or Mastercard).',
'card_cvc_invalid' => 'Please enter a valid security code (CVC).',
]; ];

View file

@ -15,6 +15,7 @@ return [
'abo_order_info_check' => 'Al completar la suscripción, se establece una entrega regular. Esta se envía y factura automáticamente el día de entrega seleccionado.', 'abo_order_info_check' => 'Al completar la suscripción, se establece una entrega regular. Esta se envía y factura automáticamente el día de entrega seleccionado.',
'abo_order_info_check_2' => 'La primera entrega y facturación se realiza el día en que se establece la suscripción. Después, el envío se realiza automáticamente el día de entrega seleccionado del mes siguiente.', 'abo_order_info_check_2' => 'La primera entrega y facturación se realiza el día en que se establece la suscripción. Después, el envío se realiza automáticamente el día de entrega seleccionado del mes siguiente.',
'abo_order_info_check_3' => 'PayPal y tarjeta de crédito están disponibles como métodos de pago. <strong>La suscripción tiene una duración mínima de :abo-min-duration meses.</strong> Después, puede pausarse, cambiarse o cancelarse en cualquier momento.', 'abo_order_info_check_3' => 'PayPal y tarjeta de crédito están disponibles como métodos de pago. <strong>La suscripción tiene una duración mínima de :abo-min-duration meses.</strong> Después, puede pausarse, cambiarse o cancelarse en cualquier momento.',
'abo_recovery_info_check' => 'Aquí pagas la factura pendiente de tu suscripción existente. <strong>El método de pago que elijas también se guardará para todos los cobros futuros de tu suscripción.</strong> Tras el pago correcto, tu suscripción se reactiva y se ejecuta periódicamente en el día de entrega elegido.',
'abo_order_info_checkbox' => '¡Sí, he entendido los términos de la suscripción!', 'abo_order_info_checkbox' => '¡Sí, he entendido los términos de la suscripción!',
'abo_order_info_checkbox_required' => 'Por favor, confirma los términos de la suscripción para continuar.', 'abo_order_info_checkbox_required' => 'Por favor, confirma los términos de la suscripción para continuar.',
'abo_infos' => 'Información de suscripción', 'abo_infos' => 'Información de suscripción',
@ -155,6 +156,16 @@ return [
'retry_payment_confirm_copy' => 'El intento de pago comenzará inmediatamente y puede activar un cargo de PayPal o tarjeta de crédito. Ejecútelo solo después de comprobar la causa.', 'retry_payment_confirm_copy' => 'El intento de pago comenzará inmediatamente y puede activar un cargo de PayPal o tarjeta de crédito. Ejecútelo solo después de comprobar la causa.',
'retry_payment_confirm_button' => 'Reintentar pago ahora', 'retry_payment_confirm_button' => 'Reintentar pago ahora',
'retry_only_hold' => 'El reintento de pago solo está disponible para suscripciones en pausa.', 'retry_only_hold' => 'El reintento de pago solo está disponible para suscripciones en pausa.',
'pay_now_title' => 'Pago fallido: pagar ahora',
'pay_now_copy' => 'El último cobro de tu suscripción ha fallado (p. ej. la tarjeta o PayPal ya no son válidos). En el checkout pagas ahora la factura pendiente con el método de pago que prefieras, que además se guardará para todos los cobros futuros de tu suscripción.',
'pay_now_button' => 'Pagar ahora',
'pay_now_not_allowed' => 'Actualmente no es posible un pago posterior para esta suscripción.',
'pay_now_failed' => 'No se pudo iniciar el checkout. Inténtalo de nuevo más tarde.',
'back_to_abo' => 'Volver a mi suscripción',
'payment_failed_mail_subject' => 'El pago de tu suscripción ha fallado',
'payment_failed_mail_intro' => 'lamentablemente el último cobro de tu suscripción ha fallado (p. ej. porque tu tarjeta de crédito o tu cuenta de PayPal ya no son válidas). Con el siguiente enlace puedes pagar ahora el importe pendiente. El método de pago que elijas también se guardará para tus próximos cobros.',
'payment_failed_mail_button' => 'Pagar ahora',
'payment_failed_mail_hint' => 'Si el botón no funciona, copia este enlace en tu navegador:',
'retry_only_active' => 'El reintento de pago solo está disponible para suscripciones activas.', 'retry_only_active' => 'El reintento de pago solo está disponible para suscripciones activas.',
'retry_already_paid_today' => 'Ya se ha registrado hoy un pago correcto para esta suscripción.', 'retry_already_paid_today' => 'Ya se ha registrado hoy un pago correcto para esta suscripción.',
'retry_error_shopping_user' => 'No se pudo crear el usuario de compra para el reintento de pago.', 'retry_error_shopping_user' => 'No se pudo crear el usuario de compra para el reintento de pago.',
@ -233,4 +244,9 @@ return [
'extern_done_already_paid' => 'Este pedido ya está pagado y no se puede gestionar externamente.', 'extern_done_already_paid' => 'Este pedido ya está pagado y no se puede gestionar externamente.',
'extern_done_success' => 'El pedido se ha marcado como gestionado externamente. Puntos abonados, bloqueo levantado.', 'extern_done_success' => 'El pedido se ha marcado como gestionado externamente. Puntos abonados, bloqueo levantado.',
'extern_done_error' => 'El pedido no se pudo marcar como gestionado: :error', 'extern_done_error' => 'El pedido no se pudo marcar como gestionado: :error',
'admin_delete' => 'Eliminar suscripción',
'admin_delete_confirm_title' => 'Eliminar suscripción',
'admin_delete_confirm_copy' => 'La suscripción se eliminará (deleted_at) y ya no aparecerá en el resumen. Continúe solo si realmente lo desea.',
'admin_delete_confirm_button' => 'Eliminar suscripción ahora',
'admin_delete_success' => 'La suscripción se ha eliminado.',
]; ];

View file

@ -15,6 +15,7 @@ return [
'abo_order_info_check' => 'À la conclusion de l’abonnement, une livraison régulière est mise en place. Elle est automatiquement expédiée et facturée au jour de livraison choisi.', 'abo_order_info_check' => 'À la conclusion de l’abonnement, une livraison régulière est mise en place. Elle est automatiquement expédiée et facturée au jour de livraison choisi.',
'abo_order_info_check_2' => 'La première livraison et facturation a lieu le jour de la création de l’abonnement. Ensuite, l’expédition se fait automatiquement au jour de livraison choisi du mois suivant.', 'abo_order_info_check_2' => 'La première livraison et facturation a lieu le jour de la création de l’abonnement. Ensuite, l’expédition se fait automatiquement au jour de livraison choisi du mois suivant.',
'abo_order_info_check_3' => 'PayPal et carte de crédit sont disponibles comme modes de paiement. <strong>L’abonnement a une durée minimale de :abo-min-duration mois.</strong> Ensuite, il peut être mis en pause, modifié ou résilié à tout moment.', 'abo_order_info_check_3' => 'PayPal et carte de crédit sont disponibles comme modes de paiement. <strong>L’abonnement a une durée minimale de :abo-min-duration mois.</strong> Ensuite, il peut être mis en pause, modifié ou résilié à tout moment.',
'abo_recovery_info_check' => 'Vous réglez ici la facture en attente de votre abonnement existant. <strong>Le moyen de paiement choisi sera également enregistré pour tous les prélèvements futurs de votre abonnement.</strong> Après un paiement réussi, votre abonnement est réactivé et exécuté régulièrement le jour de livraison choisi.',
'abo_order_info_checkbox' => 'Oui, j’ai compris les conditions de l’abonnement !', 'abo_order_info_checkbox' => 'Oui, j’ai compris les conditions de l’abonnement !',
'abo_order_info_checkbox_required' => 'Veuillez confirmer les conditions de l’abonnement pour continuer.', 'abo_order_info_checkbox_required' => 'Veuillez confirmer les conditions de l’abonnement pour continuer.',
'abo_infos' => 'Infos abonnement', 'abo_infos' => 'Infos abonnement',
@ -155,6 +156,16 @@ return [
'retry_payment_confirm_copy' => 'La tentative de paiement démarre immédiatement et peut déclencher un débit PayPal ou carte de crédit. À exécuter uniquement si la cause a été vérifiée.', 'retry_payment_confirm_copy' => 'La tentative de paiement démarre immédiatement et peut déclencher un débit PayPal ou carte de crédit. À exécuter uniquement si la cause a été vérifiée.',
'retry_payment_confirm_button' => 'Réessayer le paiement maintenant', 'retry_payment_confirm_button' => 'Réessayer le paiement maintenant',
'retry_only_hold' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements en pause.', 'retry_only_hold' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements en pause.',
'pay_now_title' => 'Paiement échoué – payer maintenant',
'pay_now_copy' => 'Le dernier prélèvement de votre abonnement a échoué (p. ex. carte ou PayPal plus valides). Dans le checkout, vous réglez maintenant la facture en attente avec un moyen de paiement de votre choix, qui sera également enregistré pour tous les prélèvements futurs de votre abonnement.',
'pay_now_button' => 'Payer maintenant',
'pay_now_not_allowed' => 'Un paiement ultérieur n’est actuellement pas possible pour cet abonnement.',
'pay_now_failed' => 'Le checkout n’a pas pu être démarré. Veuillez réessayer plus tard.',
'back_to_abo' => 'Retour à mon abonnement',
'payment_failed_mail_subject' => 'Le paiement de votre abonnement a échoué',
'payment_failed_mail_intro' => 'malheureusement, le dernier prélèvement de votre abonnement a échoué (p. ex. parce que votre carte de crédit ou votre compte PayPal n’est plus valide). Grâce au lien ci-dessous, vous pouvez régler le montant en attente dès maintenant. Le moyen de paiement choisi sera également enregistré pour vos prochains prélèvements.',
'payment_failed_mail_button' => 'Payer maintenant',
'payment_failed_mail_hint' => 'Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :',
'retry_only_active' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements actifs.', 'retry_only_active' => 'La nouvelle tentative de paiement n’est possible que pour les abonnements actifs.',
'retry_already_paid_today' => 'Un paiement réussi a déjà été enregistré aujourd’hui pour cet abonnement.', 'retry_already_paid_today' => 'Un paiement réussi a déjà été enregistré aujourd’hui pour cet abonnement.',
'retry_error_shopping_user' => 'Le Shopping-User n’a pas pu être créé pour la nouvelle tentative de paiement.', 'retry_error_shopping_user' => 'Le Shopping-User n’a pas pu être créé pour la nouvelle tentative de paiement.',
@ -233,4 +244,9 @@ return [
'extern_done_already_paid' => 'Cette commande est déjà payée et ne peut pas être traitée en externe.', 'extern_done_already_paid' => 'Cette commande est déjà payée et ne peut pas être traitée en externe.',
'extern_done_success' => 'La commande a été marquée comme traitée en externe. Points crédités, blocage levé.', 'extern_done_success' => 'La commande a été marquée comme traitée en externe. Points crédités, blocage levé.',
'extern_done_error' => 'La commande n’a pas pu être marquée comme traitée : :error', 'extern_done_error' => 'La commande n’a pas pu être marquée comme traitée : :error',
'admin_delete' => 'Supprimer l’abonnement',
'admin_delete_confirm_title' => 'Supprimer l’abonnement',
'admin_delete_confirm_copy' => 'L’abonnement sera supprimé (deleted_at) et n’apparaîtra plus dans l’aperçu. Ne continuez que si cela est vraiment souhaité.',
'admin_delete_confirm_button' => 'Supprimer l’abonnement maintenant',
'admin_delete_success' => 'L’abonnement a été supprimé.',
]; ];

View file

@ -26,7 +26,7 @@
<div class="col-md-3 mb-3"> <div class="col-md-3 mb-3">
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div> <div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
{!! $user_abo->getStatusFormated() !!} &nbsp;{!! get_active_badge($user_abo->active) !!} {!! $user_abo->getStatusFormated() !!} &nbsp;{!! get_active_badge($user_abo->active) !!}
@if ($user_abo->status === 3 && $user_abo->active) @if ($isAdmin && $user_abo->status === 3 && $user_abo->active)
<button type="button" class="btn btn-sm btn-danger mt-3" data-toggle="modal" <button type="button" class="btn btn-sm btn-danger mt-3" data-toggle="modal"
data-target="#modal-retry-abo-payment"> data-target="#modal-retry-abo-payment">
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }} <span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
@ -64,6 +64,9 @@
data-view="admin" data-route="{{ route('modal_load') }}"><span data-view="admin" data-route="{{ route('modal_load') }}"><span
class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button> class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button>
<button type="button" class="btn btn-sm btn-danger" data-toggle="modal"
data-target="#modal-abo-delete"><span class="fa fa-trash"></span> {{ __('abo.admin_delete') }}</button>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">

View file

@ -1,49 +0,0 @@
@if ($user_abo->status === 3 && $user_abo->active)
<div class="text-right mt-3">
<button type="button" class="btn btn-warning" data-toggle="modal" data-target="#modal-retry-abo-payment">
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
</button>
</div>
<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">
<div class="modal-content">
{!! Form::open(['action' => $retryAction]) !!}
<div class="modal-header">
<h5 class="modal-title" id="modal-retry-abo-payment-label">{{ __('abo.retry_payment') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>{{ __('abo.retry_payment_confirm_title') }}</strong><br>
{{ __('abo.retry_payment_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.payment') }}:</td>
<td>{{ $user_abo->getPaymentType() }}</td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.amount') }}:</td>
<td>{{ $user_abo->getFormattedAmount() }} €</td>
</tr>
<tr>
<td class="font-weight-bold">{{ __('tables.next_date') }}:</td>
<td>{{ $user_abo->next_date }}</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-warning">{{ __('abo.retry_payment_confirm_button') }}</button>
</div>
{!! Form::close() !!}
</div>
</div>
</div>
@endif

View file

@ -0,0 +1,108 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ __('abo.payment_failed_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.payment_failed_mail_subject') }}</b>
</td>
</tr>
<tr>
<td style="color:#37302d;line-height:1.6em;">
{{ $salutation }},
<br><br>
{{ __('abo.payment_failed_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.payment_failed_mail_button') }}
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="color:#7B7B7E; font-size: 13px; line-height:1.6em;">
{{ __('abo.payment_failed_mail_hint') }}
<br>
<a href="{{ $link }}" style="color:#7B7B7E; text-decoration: underline; word-break: break-all;">{{ $link }}</a>
</td>
</tr>
<tr>
<td style="color:#37302d;line-height:1.6em;">
<br>
{{ $greetings }}<br>
{{ $sender }}
</td>
</tr>
</table>
<center>
<table cellpadding="0" cellspacing="0" class="force-full-width" bgcolor="#f8f8f8" style="margin: 0 auto; text-align: center;line-height:1.6em;">
<tr>
<td style="color:#7B7B7E; font-size:14px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy1')}}</p>
<br>
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
<br>
</td>
</tr>
<tr>
<td style="color:#bbbbbb; font-size:12px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy2')}}</p>
<a href="https://www.mivita.care/datenschutz">{{ __('dataprotect.data_protect') }}</a> <br>
<p>{{__('email.footer_copy3')}}</p>
<br><br>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -26,10 +26,10 @@
</div> </div>
@endif @endif
@if($user_abo->isPendingInitialPayment()) @if ($user_abo->isPendingInitialPayment())
<div class="alert alert-warning"> <div class="alert alert-warning">
<strong>{{ __('abo.pending_payment_waiting_title') }}</strong><br> <strong>{{ __('abo.pending_payment_waiting_title') }}</strong><br>
@if($user_abo->hasFailedInitialPayment()) @if ($user_abo->hasFailedInitialPayment())
{{ __('abo.pending_payment_failed_copy') }} {{ __('abo.pending_payment_failed_copy') }}
@elseif($user_abo->hasInitialPaymentStatus()) @elseif($user_abo->hasInitialPaymentStatus())
{{ __('abo.pending_payment_processing_copy') }} {{ __('abo.pending_payment_processing_copy') }}
@ -39,6 +39,22 @@
</div> </div>
@endif @endif
@if ($user_abo->status === 3 && $user_abo->active)
<div class="card mt-3 mb-3 border-info">
<div class="card-body">
<h6 class="font-weight-bold text-info">{{ __('abo.pay_now_title') }}</h6>
<p class="mb-2">{{ __('abo.pay_now_copy') }}</p>
<form method="POST" action="{{ route('portal_abos_pay_now', [$user_abo->id]) }}">
@csrf
<button type="submit" class="btn btn-info">
<i class="ion ion-md-card mr-1"></i>{{ __('abo.pay_now_button') }}
</button>
</form>
</div>
</div>
@endif
<div class="card"> <div class="card">
@include('admin.abo._detail') @include('admin.abo._detail')
</div> </div>
@ -95,7 +111,10 @@
</div> </div>
@else @else
<div class="card mt-3"> <div class="card mt-3">
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode, 'change_locked' => $aboChangeLocked]) @include('admin.abo._order_abo', [
'add_only_mode' => $addOnlyMode,
'change_locked' => $aboChangeLocked,
])
</div> </div>
<div id="holder_html_view_comp_product"> <div id="holder_html_view_comp_product">
@ -114,7 +133,41 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@include('admin.abo._retry_payment_modal', ['retryAction' => route('portal_abos_retry_payment', [$user_abo->id])]) @if ($user_abo->isCreditCard())
<div class="card mt-3 mb-3">
<div class="card-body">
<h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6>
<p class="mb-2">
{{ __('payment.credit_card') }}@if ($user_abo->maskedCard())
: {{ $user_abo->maskedCard() }}
@endif
</p>
@if ($user_abo->canUpdatePaymentMethod())
<a href="{{ route('portal_abos_payment_method_edit', [$user_abo->id]) }}"
class="btn btn-sm btn-primary">
{{ __('abo.update_payment_method') }}
</a>
@endif
@if ($user_abo->card_changes->isNotEmpty())
<div class="mt-3">
<small class="text-muted d-block mb-1">{{ __('abo.card_change_history') }}</small>
<ul class="list-unstyled mb-0 small">
@foreach ($user_abo->card_changes->take(5) as $change)
<li>
<i class="ion ion-md-time text-muted mr-1"></i>
{{ $change->created_at->format('d.m.Y H:i') }}@if ($change->masked_card)
— {{ $change->masked_card }}
@endif
</li>
@endforeach
</ul>
</div>
@endif
</div>
</div>
@endif
<a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a> <a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a>
@ -130,7 +183,5 @@
var iqAboOneTime = IqAboOneTime.init(); var iqAboOneTime = IqAboOneTime.init();
$( document ).ready(function() { $( document ).ready(function() {
}); });
</script> </script>
@endsection @endsection

View file

@ -43,9 +43,26 @@
</div> </div>
</div> </div>
@endif @endif
@if ($view === 'me' && $user_abo->status === 3 && $user_abo->active)
<div class="card mt-3 mb-3 border-info">
<div class="card-body">
<h6 class="font-weight-bold text-info">{{ __('abo.pay_now_title') }}</h6>
<p class="mb-2">{{ __('abo.pay_now_copy') }}</p>
<form method="POST" action="{{ route('user_abos_pay_now', [$view, $user_abo->id]) }}">
@csrf
<button type="submit" class="btn btn-info">
<i class="ion ion-md-card mr-1"></i>{{ __('abo.pay_now_button') }}
</button>
</form>
</div>
</div>
@endif
<div class="card"> <div class="card">
@include('admin.abo._detail') @include('admin.abo._detail')
</div> </div>
<div class="card mt-3"> <div class="card mt-3">
@include('admin.abo._detail_abo_info') @include('admin.abo._detail_abo_info')
</div> </div>
@ -103,7 +120,10 @@
</div> </div>
@else @else
<div class="card mt-3"> <div class="card mt-3">
@include('admin.abo._order_abo', ['add_only_mode' => $addOnlyMode, 'change_locked' => $aboChangeLocked]) @include('admin.abo._order_abo', [
'add_only_mode' => $addOnlyMode,
'change_locked' => $aboChangeLocked,
])
</div> </div>
<div id="holder_html_view_comp_product"> <div id="holder_html_view_comp_product">
@ -121,7 +141,52 @@
@include('admin.abo._executions') @include('admin.abo._executions')
</div> </div>
@include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])]) @if ($user_abo->isCreditCard())
<div class="card mt-3 mb-3">
<div class="card-body">
<h6 class="font-weight-bold">{{ __('abo.payment_method') }}</h6>
<p class="mb-2">
{{ __('payment.credit_card') }}@if ($user_abo->maskedCard())
: {{ $user_abo->maskedCard() }}
@endif
</p>
@if ($view === 'me' && $user_abo->canUpdatePaymentMethod())
<a href="{{ route('user_abos_payment_method_edit', [$view, $user_abo->id]) }}"
class="btn btn-sm btn-primary">
{{ __('abo.update_payment_method') }}
</a>
@endif
@if ($view === 'ot' && $user_abo->canUpdatePaymentMethod())
<form method="POST" action="{{ route('user_abos_payment_method_send_link', [$view, $user_abo->id]) }}"
class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="ion ion-md-mail mr-1"></i>{{ __('abo.card_update_link_button') }}
</button>
</form>
@endif
@if ($user_abo->card_changes->isNotEmpty())
<div class="mt-3">
<small class="text-muted d-block mb-1">{{ __('abo.card_change_history') }}</small>
<ul class="list-unstyled mb-0 small">
@foreach ($user_abo->card_changes->take(5) as $change)
<li>
<i class="ion ion-md-time text-muted mr-1"></i>
{{ $change->created_at->format('d.m.Y H:i') }}@if ($change->masked_card)
— {{ $change->masked_card }}
@endif
</li>
@endforeach
</ul>
</div>
@endif
</div>
</div>
@endif
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a> <a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>

View file

@ -1,9 +1,9 @@
@extends($user_shop ?'web.user.layouts.layout' : 'web.layouts.layout') @extends($user_shop ? 'web.user.layouts.layout' : 'web.layouts.layout')
@section('content') @section('content')
<section class="page-header page-header-xlg parallax parallax-3" <section class="page-header page-header-xlg parallax parallax-3"
style="background-image:url('/assets/images/vision-min.jpg')"> style="background-image:url('/assets/images/vision-min.jpg')">
<div class="overlay dark-1"><!-- dark overlay [1 to 9 opacity] --></div> <div class="overlay dark-1"><!-- dark overlay [1 to 9 opacity] --></div>
<div class="container"> <div class="container">
@ -13,75 +13,91 @@
<style> <style>
div.shop-item { div.shop-item {
margin-bottom:30px; margin-bottom: 30px;
border: 1px solid #ddd; border: 1px solid #ddd;
} }
div.shop-item > .thumbnail, .thumbnail {
div.shop-item>.thumbnail,
.thumbnail {
border: none; border: none;
} }
div.shop-item-summary { div.shop-item-summary {
padding: 8px; padding: 8px;
} }
div.shop-item-summary h2 a { div.shop-item-summary h2 a {
color: #9aa983; color: #9aa983;
font-size: 1.2em; font-size: 1.2em;
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
div.shop-item-buttons { div.shop-item-buttons {
padding: 0 8px 10px 8px; padding: 0 8px 10px 8px;
} }
div.shop-item-buttons .btn-xs{
div.shop-item-buttons .btn-xs {
padding: 4px; padding: 4px;
} }
.cartContent a.remove_item { .cartContent a.remove_item {
background: transparent; background: transparent;
} }
.cartContent .product_name { .cartContent .product_name {
font-size: 1.15em; font-size: 1.15em;
} }
.cartContent .product_name > small {
.cartContent .product_name>small {
line-height: 20px; line-height: 20px;
} }
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
.cartContent .product_name { .cartContent .product_name {
padding-bottom: 0; padding-bottom: 0;
min-height: 60px; min-height: 60px;
width: 60%; width: 60%;
} }
.cartContent .remove_item { .cartContent .remove_item {
clear: right; clear: right;
} }
.cartContent .total_price { .cartContent .total_price {
width: auto; width: auto;
padding-top: 30px; padding-top: 30px;
clear: right; clear: right;
} }
.cartContent .item .qty { .cartContent .item .qty {
float: left; float: left;
text-align: left; text-align: left;
} }
.cartContent .item.head { .cartContent .item.head {
display: none; display: none;
} }
} }
@media only screen and (min-width: 768px) { @media only screen and (min-width: 768px) {
.cartContent .total_price { .cartContent .total_price {
padding-top: 10px; padding-top: 10px;
} }
} }
@media only screen and (min-width: 992px) { @media only screen and (min-width: 992px) {
.cartContent .total_price { .cartContent .total_price {
padding-top: 30px; padding-top: 30px;
} }
} }
@media only screen and (min-width: 1200px) { @media only screen and (min-width: 1200px) {
.cartContent .total_price { .cartContent .total_price {
padding-top: 10px; padding-top: 10px;
} }
} }
</style> </style>
<!-- --> <!-- -->
@ -98,80 +114,111 @@
<h4>{{ __('payment.your_order_number_is') }}: {{ $order_reference }}</h4> <h4>{{ __('payment.your_order_number_is') }}: {{ $order_reference }}</h4>
<hr /> <hr />
@if($pay_trans && $pay_trans->shopping_payment) @if ($pay_trans && $pay_trans->shopping_payment)
@if($pay_trans->shopping_payment->clearingtype === "vor") @if ($pay_trans->shopping_payment->clearingtype === 'vor')
{{-- Prominenter Vorkasse-Hinweis mit TXID und Bankdaten --}} {{-- Prominenter Vorkasse-Hinweis mit TXID und Bankdaten --}}
<div class="alert alert-warning" style="border: 3px solid #ffc107; background-color: #fff8e1;"> <div class="alert alert-warning" style="border: 3px solid #ffc107; background-color: #fff8e1;">
<h4 class="alert-heading"><i class="fa fa-exclamation-triangle"></i> {{ __('payment.prepayment_important_notice') }}</h4> <h4 class="alert-heading"><i class="fa fa-exclamation-triangle"></i>
{{ __('payment.prepayment_important_notice') }}</h4>
<h5 class="mb-3"><i class="fa fa-university"></i> {{ __('payment.prepayment_bank_details') }}:</h5>
<div style="background: #fff; border-radius: 8px; padding: 15px;">
<table style="width: 100%;">
<tr>
<td style="padding: 5px 10px 5px 0; width: 140px;"><strong>{{ __('payment.account_holder') }}:</strong></td>
<td style="padding: 5px 0;">{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong></td>
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">{{ $pay_trans->transmitted_data['clearing_bankiban'] }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong></td>
<td style="padding: 5px 0; font-family: monospace;">{{ $pay_trans->transmitted_data['clearing_bankbic'] }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong></td>
<td style="padding: 5px 0;">{{ $pay_trans->transmitted_data['clearing_bankname'] }}</td>
</tr>
<tr style="border-top: 1px solid #ffc107;">
<td style="padding: 10px 10px 5px 0;"><strong>{{ __('payment.total_amount') }}:</strong></td>
<td style="padding: 10px 0 5px 0; font-size: 18px; font-weight: bold; color: #d35400;">{{ number_format( floatval($pay_trans->shopping_payment->amount/100), 2, ",", ".") }} EUR</td>
</tr>
</table>
</div>
<hr style="border-color: #ffc107;">
<p>{{ __('payment.prepayment_reference_notice') }}</p>
<h5 class="mb-3"><i class="fa fa-university"></i>
@if(isset($pay_trans->transmitted_data['txid'])) {{ __('payment.prepayment_bank_details') }}:</h5>
<div class="text-center p-4 my-3" style="background: #fff; border: 3px dashed #ffc107; border-radius: 8px;"> <div style="background: #fff; border-radius: 8px; padding: 15px;">
<strong style="font-size: 16px;">{{ __('payment.prepayment_txid') }}:</strong><br> <table style="width: 100%;">
<span style="font-size: 32px; font-weight: bold; color: #d35400; letter-spacing: 3px;">{{ $pay_trans->transmitted_data['txid'] }}</span> <tr>
</div> <td style="padding: 5px 10px 5px 0; width: 140px;">
@endif <strong>{{ __('payment.account_holder') }}:</strong>
</td>
<p><strong>{{ __('payment.prepayment_reference_only') }}</strong></p> <td style="padding: 5px 0;">
{{ $pay_trans->transmitted_data['clearing_bankaccountholder'] }}</td>
</div> </tr>
<hr /> <tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.IBAN') }}:</strong>
</td>
<td style="padding: 5px 0; font-family: monospace; letter-spacing: 1px;">
{{ $pay_trans->transmitted_data['clearing_bankiban'] }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.BIC') }}:</strong>
</td>
<td style="padding: 5px 0; font-family: monospace;">
{{ $pay_trans->transmitted_data['clearing_bankbic'] }}</td>
</tr>
<tr>
<td style="padding: 5px 10px 5px 0;"><strong>{{ __('payment.bank') }}:</strong>
</td>
<td style="padding: 5px 0;">
{{ $pay_trans->transmitted_data['clearing_bankname'] }}</td>
</tr>
<tr style="border-top: 1px solid #ffc107;">
<td style="padding: 10px 10px 5px 0;">
<strong>{{ __('payment.total_amount') }}:</strong>
</td>
<td
style="padding: 10px 0 5px 0; font-size: 18px; font-weight: bold; color: #d35400;">
{{ number_format(floatval($pay_trans->shopping_payment->amount / 100), 2, ',', '.') }}
EUR</td>
</tr>
</table>
</div>
<hr style="border-color: #ffc107;">
<p>{{ __('payment.prepayment_reference_notice') }}</p>
@if (isset($pay_trans->transmitted_data['txid']))
<div class="text-center p-4 my-3"
style="background: #fff; border: 3px dashed #ffc107; border-radius: 8px;">
<strong style="font-size: 16px;">{{ __('payment.prepayment_txid') }}:</strong><br>
<span
style="font-size: 32px; font-weight: bold; color: #d35400; letter-spacing: 3px;">{{ $pay_trans->transmitted_data['txid'] }}</span>
</div>
@endif
<p><strong>{{ __('payment.prepayment_reference_only') }}</strong></p>
</div>
<hr />
@endif @endif
@if($pay_trans->shopping_payment->clearingtype === "cc") @if ($pay_trans->shopping_payment->clearingtype === 'cc')
<h4>{{ __('payment.payment_by_credit_card') }}:</h4> <h4>{{ __('payment.payment_by_credit_card') }}:</h4>
<p>{{ __('payment.payment_by_credit_card_info') }}</p> <p>{{ __('payment.payment_by_credit_card_info') }}</p>
<hr /> <hr />
@endif @endif
@if($pay_trans->shopping_payment->clearingtype === "elv") @if ($pay_trans->shopping_payment->clearingtype === 'elv')
<h4>{{ __('payment.payment_by_SEPA') }}:</h4> <h4>{{ __('payment.payment_by_SEPA') }}:</h4>
<p>{{ __('payment.payment_by_SEPA_info') }}</p> <p>{{ __('payment.payment_by_SEPA_info') }}</p>
<hr /> <hr />
@endif @endif
@if($pay_trans->shopping_payment->clearingtype === "fnc" && $pay_trans->shopping_payment->onlinebanktransfertype === 'MIV') @if (
<h4>{{ __('payment.payment_by_invoice') }}:</h4> $pay_trans->shopping_payment->clearingtype === 'fnc' &&
<p>{{ __('payment.payment_by_invoice_info') }}</p> $pay_trans->shopping_payment->onlinebanktransfertype === 'MIV')
<hr /> <h4>{{ __('payment.payment_by_invoice') }}:</h4>
@endif <p>{{ __('payment.payment_by_invoice_info') }}</p>
<hr />
@endif
@endif @endif
<p> <p>
<strong>{{ __('payment.your_mivita_team') }}</strong> <strong>{{ __('payment.your_mivita_team') }}</strong>
</p> </p>
<hr />
<div class="clearfix">
<a href="{{ ($back_url ?? null) ?: Util::getUserShopBackUrl($order_reference) }}"
class="btn float-left btn-primary btn-text-500">
<i class="fa fa-shopping-bag"></i>
{{ ($back_url ?? null) ? __('abo.back_to_abo') : __('payment.back_to_shop') }}
</a>
<a href="{{ route('withdrawal.create') }}" class="btn float-right btn-xs btn-default btn-text-500">
<i class="fa fa-undo"></i> {{ __('withdrawal.button_label') }}
</a>
</div>
</div> </div>
</div> </div>
<!-- /CHECKOUT FINAL MESSAGE --> <!-- /CHECKOUT FINAL MESSAGE -->
@ -179,4 +226,4 @@
</div> </div>
</section> </section>
<!-- / --> <!-- / -->
@endsection @endsection

View file

@ -381,6 +381,7 @@
{!! Form::hidden('shopping_collect_order_id', $shopping_user->shopping_collect_order_id) !!} {!! Form::hidden('shopping_collect_order_id', $shopping_user->shopping_collect_order_id) !!}
{!! Form::hidden('is_abo', $is_abo) !!} {!! Form::hidden('is_abo', $is_abo) !!}
{!! Form::hidden('abo_interval', $abo_interval) !!} {!! Form::hidden('abo_interval', $abo_interval) !!}
{!! Form::hidden('recovery_user_abo_id', $recovery_user_abo_id ?? null) !!}
@if (\Session::has('elv-managemandate')) @if (\Session::has('elv-managemandate'))
<div class="col-sm-12"> <div class="col-sm-12">
@ -1021,6 +1022,9 @@
<h4>{{ __('abo.abo_delivery') }}</h4> <h4>{{ __('abo.abo_delivery') }}</h4>
<hr> <hr>
<div style="background-color: #fff7da; padding: 10px; border-radius: 5px;"> <div style="background-color: #fff7da; padding: 10px; border-radius: 5px;">
@if(!empty($recovery_user_abo_id))
<em class="small mb-4">{!! __('abo.abo_recovery_info_check') !!}</em>
@else
<em class="small mb-4">{!! __('abo.abo_order_info_check') !!}</em> <em class="small mb-4">{!! __('abo.abo_order_info_check') !!}</em>
<hr style="margin-top: 10px; margin-bottom: 10px; border-color: #b4b4b4; border-width: 1px;"> <hr style="margin-top: 10px; margin-bottom: 10px; border-color: #b4b4b4; border-width: 1px;">
@ -1028,14 +1032,14 @@
<hr style="margin-top: 10px; margin-bottom: 10px; border-color: #b4b4b4; border-width: 1px;"> <hr style="margin-top: 10px; margin-bottom: 10px; border-color: #b4b4b4; border-width: 1px;">
<em class="small mb-4">{!! __('abo.abo_order_info_check_3', ['abo-min-duration' => \App\Models\Setting::getContentBySlug('abo-min-duration')]) !!}</em> <em class="small mb-4">{!! __('abo.abo_order_info_check_3', ['abo-min-duration' => \App\Models\Setting::getContentBySlug('abo-min-duration')]) !!}</em>
<hr> <hr>
<div class="clearfix mb-2"> <div class="clearfix mb-2">
<span class="pull-right small text-right"> <span class="pull-right small text-right">
<div class="no-line-break"><strong> {{ now()->format('d.m.Y') }}</strong></div> <div class="no-line-break"><strong> {{ now()->format('d.m.Y') }}</strong></div>
</span> </span>
<span class="pull-left small"><strong>{{ __('abo.abo_first_execution_date') }}:</strong></span> <span class="pull-left small"><strong>{{ __('abo.abo_first_execution_date') }}:</strong></span>
</div> </div>
<div class="clearfix mb-2"> <div class="clearfix mb-2">
<span class="pull-right small text-right"> <span class="pull-right small text-right">
<div class="no-line-break"><strong> {{ HTMLHelper::getAboStrLang($abo_interval) }}</strong></div> <div class="no-line-break"><strong> {{ HTMLHelper::getAboStrLang($abo_interval) }}</strong></div>
@ -1048,6 +1052,7 @@
</span> </span>
<span class="pull-left small"><strong>{{ __('abo.abo_next_execution_date') }}:</strong></span> <span class="pull-left small"><strong>{{ __('abo.abo_next_execution_date') }}:</strong></span>
</div> </div>
@endif
</div> </div>
</fieldset> </fieldset>
@endif @endif

View file

@ -162,7 +162,11 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail'); Route::get('/user/abos/detail/{view}/{id}', 'User\AboController@detail')->name('user_abos_detail');
Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update'); Route::post('/user/abos/update/{view}/{id}', 'User\AboController@update')->name('user_abos_update');
Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime'); Route::post('/user/abos/onetime/{view}/{id}', 'User\AboController@oneTime')->name('user_abos_onetime');
Route::post('/user/abos/retry-payment/{view}/{id}', 'User\AboController@retryPayment')->name('user_abos_retry_payment'); Route::post('/user/abos/pay-now/{view}/{id}', 'User\AboController@payNow')->name('user_abos_pay_now');
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');
Route::post('/user/abos/payment-method/send-link/{view}/{id}', 'User\AboController@sendCardUpdateLink')->name('user_abos_payment_method_send_link');
Route::post('/user/abos/delete-pending/{view}/{id}', 'User\AboController@deletePending')->name('user_abos_delete_pending'); Route::post('/user/abos/delete-pending/{view}/{id}', 'User\AboController@deletePending')->name('user_abos_delete_pending');
Route::post('/user/abos/payment-link/delete/{identifier}', 'User\AboController@deletePaymentLink')->name('user_abos_payment_link_delete'); Route::post('/user/abos/payment-link/delete/{identifier}', 'User\AboController@deletePaymentLink')->name('user_abos_payment_link_delete');
Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable'); Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable');
@ -359,12 +363,19 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/admin/sales/invoice', 'SalesController@invoice')->name('admin_sales_invoice'); Route::post('/admin/sales/invoice', 'SalesController@invoice')->name('admin_sales_invoice');
Route::post('/admin/sales/invoice/cancellation', 'SalesController@invoiceCancellation')->name('admin_sales_invoice_cancellation'); Route::post('/admin/sales/invoice/cancellation', 'SalesController@invoiceCancellation')->name('admin_sales_invoice_cancellation');
// Widerrufe (gesetzliche Online-Widerrufsfunktion)
Route::get('/admin/withdrawals', 'Admin\OrderWithdrawalController@index')->name('admin_withdrawals');
Route::get('/admin/withdrawals/datatable', 'Admin\OrderWithdrawalController@datatable')->name('admin_withdrawals_datatable');
Route::get('/admin/withdrawals/detail/{id}', 'Admin\OrderWithdrawalController@detail')->name('admin_withdrawals_detail');
Route::post('/admin/withdrawals/detail/{id}', 'Admin\OrderWithdrawalController@update')->name('admin_withdrawals_update');
Route::get('/admin/abos', 'Admin\AboController@index')->name('admin_abos'); Route::get('/admin/abos', 'Admin\AboController@index')->name('admin_abos');
Route::get('/admin/abos/detail/{id}', 'Admin\AboController@detail')->name('admin_abos_detail'); Route::get('/admin/abos/detail/{id}', 'Admin\AboController@detail')->name('admin_abos_detail');
Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update'); Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update');
Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback'); Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback');
Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment'); Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment');
Route::post('/admin/abos/extern-done/{id}', 'Admin\AboController@externDone')->name('admin_abos_extern_done'); Route::post('/admin/abos/extern-done/{id}', 'Admin\AboController@externDone')->name('admin_abos_extern_done');
Route::delete('/admin/abos/{id}', 'Admin\AboController@destroy')->name('admin_abos_delete');
Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable'); Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable');
// incentives // incentives

View file

@ -12,6 +12,7 @@ use App\Http\Controllers\Portal\Auth\LoginController;
use App\Http\Controllers\Portal\CustomerController; use App\Http\Controllers\Portal\CustomerController;
use App\Http\Controllers\Portal\InController; use App\Http\Controllers\Portal\InController;
use App\Http\Controllers\Portal\OrderController; use App\Http\Controllers\Portal\OrderController;
use App\Http\Controllers\Web\AboCardUpdateController;
/* ROUTING FOR CRM my.mivita / CMS */ /* ROUTING FOR CRM my.mivita / CMS */
@ -33,6 +34,12 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
Route::get('portal/logout', [LoginController::class, 'logout'])->name('portal.logout'); Route::get('portal/logout', [LoginController::class, 'logout'])->name('portal.logout');
Route::post('portal/logout-change', [LoginController::class, 'logoutChange'])->name('portal.logout_change'); Route::post('portal/logout-change', [LoginController::class, 'logoutChange'])->name('portal.logout_change');
// Login-freier Kreditkarten-Update-Link (Berater → Kunde); Kunde gibt selbst ein. Bewusst
// ausserhalb von auth:customers, da der Kunde nicht eingeloggt ist (Token = Berechtigung).
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');
// Geschützte Kunden-Routen // Geschützte Kunden-Routen
Route::middleware('auth:customers')->group(function () { Route::middleware('auth:customers')->group(function () {
@ -49,7 +56,10 @@ Route::domain(config('app.pre_url_portal').config('app.domain').config('app.tld_
Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create'); Route::match(['get', 'post'], 'portal/my-subscriptions/create/{step}', [AboController::class, 'myAboCreate'])->name('portal.my_subscriptions.create');
Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update'); Route::post('portal/my-subscriptions/update/{view}/{id}', [AboController::class, 'update'])->name('user_abos_update');
Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime'); Route::post('portal/my-subscriptions/onetime/{view}/{id}', [AboController::class, 'oneTime'])->name('user_abos_onetime');
Route::post('portal/my-subscriptions/retry-payment/{id}', [AboController::class, 'retryPayment'])->name('portal_abos_retry_payment'); Route::post('portal/my-subscriptions/pay-now/{id}', [AboController::class, 'payNow'])->name('portal_abos_pay_now');
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');
Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable'); Route::get('portal/my-subscriptions/datatable/{id}', [AboController::class, 'datatable'])->name('user_abo_datatable');
Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable'); Route::get('portal/my-subscriptions/onetime-datatable/{id}', [AboController::class, 'oneTimeDatatable'])->name('user_abo_onetime_datatable');
Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load'); Route::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load');

View file

@ -0,0 +1,42 @@
<?php
use App\Mail\AboPaymentFailedMail;
use App\Models\UserAbo;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeFailedAbo(string $isFor): UserAbo
{
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => 1,
'is_for' => $isFor,
'email' => 'owner-'.uniqid('', true).'@example.com',
'payone_userid' => 500100,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'abo_interval' => 5,
'next_date' => now()->toDateString(),
]);
}
it('verlinkt bei einem Berater-Abo (me) auf die CRM-Detailseite', function () {
$abo = makeFailedAbo('me');
$mail = (new AboPaymentFailedMail($abo))->build();
expect($mail->viewData['link'])->toContain('/user/abos/detail/me/'.$abo->id);
expect($mail->subject)->toBe(__('abo.payment_failed_mail_subject'));
});
it('verlinkt bei einem Kunden-Abo (ot) auf das Kundenportal', function () {
$abo = makeFailedAbo('ot');
$mail = (new AboPaymentFailedMail($abo))->build();
expect($mail->viewData['link'])->toContain('/portal/my-subscriptions');
expect($mail->viewData['link'])->not->toContain('/user/abos/detail');
});

View file

@ -0,0 +1,113 @@
<?php
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Services\AboHelper;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
function makeRecoveryAbo(): UserAbo
{
return UserAbo::create([
'user_id' => 1,
'member_id' => 1,
'shopping_user_id' => 1,
'auth_user_id' => 1,
'is_for' => 'me',
'email' => 'recovery-'.uniqid('', true).'@example.com',
'payone_userid' => 770100,
'clearingtype' => 'cc',
'active' => true,
'status' => 3,
'abo_interval' => 5,
'next_date' => now()->subDays(3)->toDateString(),
'last_date' => now()->subMonth()->toDateString(),
]);
}
function makeRecoveryPayment(UserAbo $abo): ShoppingPayment
{
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 1,
'recovery_user_abo_id' => $abo->id,
'is_abo' => true,
'abo_interval' => 5,
'paid' => false,
]);
return ShoppingPayment::create([
'shopping_order_id' => $order->id,
'reference' => uniqid('ref', true),
'clearingtype' => 'cc',
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => 5,
]);
}
it('schreibt das bestehende Abo bei erfolgreicher Recovery-Zahlung fort, ohne ein neues anzulegen', function () {
$abo = makeRecoveryAbo();
$payment = makeRecoveryPayment($abo);
AboHelper::advanceAboAfterRecoveryPayment($payment);
$abo->refresh();
expect($abo->status)->toBe(2);
expect((bool) $abo->active)->toBeTrue();
expect(\Carbon\Carbon::parse($abo->next_date)->isFuture())->toBeTrue();
expect(UserAbo::count())->toBe(1);
expect(
UserAboOrder::where('shopping_order_id', $payment->shopping_order_id)
->where('user_abo_id', $abo->id)
->where('paid', true)
->exists()
)->toBeTrue();
});
it('ist idempotent und legt bei erneutem Aufruf keinen zweiten UserAboOrder an', function () {
$abo = makeRecoveryAbo();
$payment = makeRecoveryPayment($abo);
AboHelper::advanceAboAfterRecoveryPayment($payment);
AboHelper::advanceAboAfterRecoveryPayment($payment);
expect(UserAboOrder::where('shopping_order_id', $payment->shopping_order_id)->count())->toBe(1);
});
it('ignoriert Zahlungen ohne recovery_user_abo_id', function () {
$abo = makeRecoveryAbo();
$order = ShoppingOrder::create([
'shopping_user_id' => 1,
'auth_user_id' => 1,
'country_id' => 1,
'user_shop_id' => 1,
'is_abo' => true,
'abo_interval' => 5,
'paid' => false,
]);
$payment = ShoppingPayment::create([
'shopping_order_id' => $order->id,
'reference' => uniqid('ref', true),
'clearingtype' => 'cc',
'amount' => 19.9,
'currency' => 'EUR',
'mode' => 'test',
'is_abo' => true,
'abo_interval' => 5,
]);
AboHelper::advanceAboAfterRecoveryPayment($payment);
$abo->refresh();
expect($abo->status)->toBe(3);
expect(UserAboOrder::count())->toBe(0);
});

View file

@ -3,7 +3,7 @@
use App\Http\Controllers\User\AboController; use App\Http\Controllers\User\AboController;
use App\Models\UserAbo; use App\Models\UserAbo;
use App\Repositories\AboRepository; use App\Repositories\AboRepository;
use App\Services\AboRetryPaymentService; use App\Services\AboRecoveryCheckoutService;
use App\User; use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -13,7 +13,7 @@ use function Pest\Laravel\mock;
uses(Tests\TestCase::class, RefreshDatabase::class); uses(Tests\TestCase::class, RefreshDatabase::class);
function makeRetryOwnerAbo(): array function makeRetryOwnerAbo(int $status = 3, bool $active = true): array
{ {
$owner = User::forceCreate([ $owner = User::forceCreate([
'email' => 'owner-'.uniqid('', true).'@example.com', 'email' => 'owner-'.uniqid('', true).'@example.com',
@ -29,8 +29,8 @@ function makeRetryOwnerAbo(): array
'email' => $owner->email, 'email' => $owner->email,
'payone_userid' => 900300, 'payone_userid' => 900300,
'clearingtype' => 'cc', 'clearingtype' => 'cc',
'active' => true, 'active' => $active,
'status' => 3, 'status' => $status,
'abo_interval' => 5, 'abo_interval' => 5,
'next_date' => now()->toDateString(), 'next_date' => now()->toDateString(),
]); ]);
@ -38,27 +38,22 @@ function makeRetryOwnerAbo(): array
return ['owner' => $owner, 'abo' => $abo]; return ['owner' => $owner, 'abo' => $abo];
} }
it('führt den User-Retry für den eigenen Abo aus und leitet zur Detailseite zurück', function () { it('startet den Recovery-Checkout für das eigene fehlgeschlagene Abo und leitet dorthin weiter', function () {
// Domain-Routen werden hostabhängig geladen; Stub für den Redirect der Methode.
Route::get('/user/abos/detail/{view}/{id}', fn () => '')->name('user_abos_detail');
app('router')->getRoutes()->refreshNameLookups();
$fixture = makeRetryOwnerAbo(); $fixture = makeRetryOwnerAbo();
$this->actingAs($fixture['owner']); $this->actingAs($fixture['owner']);
mock(AboRetryPaymentService::class) mock(AboRecoveryCheckoutService::class)
->shouldReceive('retry') ->shouldReceive('buildCheckoutUrl')
->once() ->once()
->andReturn(['success' => true, 'message' => 'OK']); ->andReturn('https://checkout.test/checkout/card/abc123');
$controller = new AboController(app(AboRepository::class)); $controller = new AboController(app(AboRepository::class));
$response = $controller->retryPayment('me', $fixture['abo']->id, app(AboRetryPaymentService::class)); $response = $controller->payNow('me', $fixture['abo']->id, app(AboRecoveryCheckoutService::class));
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id); expect($response->getTargetUrl())->toBe('https://checkout.test/checkout/card/abc123');
expect(session('alert-success'))->toBe('OK');
}); });
it('verhindert den User-Retry für ein fremdes Abo (403)', function () { it('verhindert die nachträgliche Zahlung für ein fremdes Abo (403)', function () {
$fixture = makeRetryOwnerAbo(); $fixture = makeRetryOwnerAbo();
$stranger = User::forceCreate([ $stranger = User::forceCreate([
@ -68,11 +63,28 @@ it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
]); ]);
$this->actingAs($stranger); $this->actingAs($stranger);
$service = mock(AboRetryPaymentService::class); $service = mock(AboRecoveryCheckoutService::class);
$service->shouldNotReceive('retry'); $service->shouldNotReceive('buildCheckoutUrl');
$controller = new AboController(app(AboRepository::class)); $controller = new AboController(app(AboRepository::class));
expect(fn () => $controller->retryPayment('me', $fixture['abo']->id, $service)) expect(fn () => $controller->payNow('me', $fixture['abo']->id, $service))
->toThrow(HttpException::class); ->toThrow(HttpException::class);
}); });
it('lehnt die nachträgliche Zahlung ab, wenn das Abo nicht fehlgeschlagen ist', function () {
Route::post('/user/abos/detail/{view}/{id}', fn () => '')->name('user_abos_detail');
app('router')->getRoutes()->refreshNameLookups();
$fixture = makeRetryOwnerAbo(status: 2);
$this->actingAs($fixture['owner']);
$service = mock(AboRecoveryCheckoutService::class);
$service->shouldNotReceive('buildCheckoutUrl');
$controller = new AboController(app(AboRepository::class));
$response = $controller->payNow('me', $fixture['abo']->id, $service);
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
expect(session('alert-error'))->toBe(__('abo.pay_now_not_allowed'));
});