Compare commits
2 commits
2269ce031f
...
ee04146217
| Author | SHA1 | Date | |
|---|---|---|---|
| ee04146217 | |||
| 8288ea59ac |
26 changed files with 892 additions and 65 deletions
|
|
@ -6,6 +6,7 @@ use App\Cron\UserMakeOrder;
|
|||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\AboOneTimeService;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use App\Services\MyLog;
|
||||
use App\Services\Payment;
|
||||
|
|
@ -74,7 +75,7 @@ class UserMakeAboOrder extends Command
|
|||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$this->error('Fehler beim Ausführen des Befehls: ' . $e->getMessage());
|
||||
$this->error('Fehler beim Ausführen des Befehls: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
|
@ -164,7 +165,7 @@ class UserMakeAboOrder extends Command
|
|||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$this->error("Fehler bei Abo {$userAbo->id}: " . $e->getMessage());
|
||||
$this->error("Fehler bei Abo {$userAbo->id}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,7 +179,7 @@ class UserMakeAboOrder extends Command
|
|||
private function makeOrder($userAbo)
|
||||
{
|
||||
\Log::channel('abo_order')->info('UserMakeAboOrder: Starte Bestellungserstellung', ['abo_id' => $userAbo->id]);
|
||||
$this->info('Starte Bestellungserstellung für Abo: ' . $userAbo->id);
|
||||
$this->info('Starte Bestellungserstellung für Abo: '.$userAbo->id);
|
||||
|
||||
$shoppingOrder = null;
|
||||
$userOrder = new UserMakeOrder($userAbo);
|
||||
|
|
@ -205,7 +206,7 @@ class UserMakeAboOrder extends Command
|
|||
]);
|
||||
|
||||
$response = $userOrder->makePayment();
|
||||
$this->info('makePayment response: ' . json_encode($response));
|
||||
$this->info('makePayment response: '.json_encode($response));
|
||||
|
||||
// Prüfe ob Response ein Array ist (kann auch Objekt sein)
|
||||
if (is_object($response)) {
|
||||
|
|
@ -274,7 +275,7 @@ class UserMakeAboOrder extends Command
|
|||
'status' => $response['status'],
|
||||
]);
|
||||
$this->info("Zahlung ausstehend für Abo {$userAbo->id}: {$response['status']}");
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: ' . $response['status']);
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Zahlung ausstehend: '.$response['status']);
|
||||
} else {
|
||||
// Unbekannter Status: Bestellung speichern, aber Abo nicht aktualisieren
|
||||
\Log::channel('abo_order')->warning('UserMakeAboOrder: Unbekannter Zahlungsstatus', [
|
||||
|
|
@ -283,7 +284,7 @@ class UserMakeAboOrder extends Command
|
|||
'status' => $response['status'],
|
||||
]);
|
||||
$this->warn("Unbekannter Zahlungsstatus für Abo {$userAbo->id}: {$response['status']}");
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: ' . $response['status']);
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Unbekannter Status: '.$response['status']);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Ausnahme bei der Bestellungserstellung', [
|
||||
|
|
@ -291,11 +292,11 @@ class UserMakeAboOrder extends Command
|
|||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$this->error("Ausnahme bei Abo {$userAbo->id}: " . $e->getMessage());
|
||||
$this->error("Ausnahme bei Abo {$userAbo->id}: ".$e->getMessage());
|
||||
|
||||
// Bestellung existiert (z. B. Fehler bei Payone): Abo-Fehlerstatus, Bestellung bleibt nachvollziehbar
|
||||
if ($shoppingOrder) {
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: ' . $e->getMessage());
|
||||
$this->updateAboOnError($userAbo, $shoppingOrder, 'Exception: '.$e->getMessage());
|
||||
|
||||
return $shoppingOrder;
|
||||
}
|
||||
|
|
@ -356,12 +357,15 @@ class UserMakeAboOrder extends Command
|
|||
// Wie bei Payment::paymentStatusPaidAction: Incentive nur wenn Callback nicht lief
|
||||
// (firstOrCreate verhindert Doppelungen wenn Payone später noch trackt)
|
||||
IncentiveTracker::trackAboActivated($shoppingOrder);
|
||||
|
||||
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
|
||||
AboOneTimeService::purgeAfterExecution($userAbo);
|
||||
} catch (\Exception $e) {
|
||||
\Log::channel('abo_order')->error('UserMakeAboOrder: Fehler beim Aktualisieren des Abos', [
|
||||
'abo_id' => $userAbo->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
|
||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage());
|
||||
throw $e; // Re-throw für besseres Error-Handling
|
||||
}
|
||||
}
|
||||
|
|
@ -421,7 +425,7 @@ class UserMakeAboOrder extends Command
|
|||
'abo_id' => $userAbo->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: " . $e->getMessage());
|
||||
$this->error("Fehler beim Aktualisieren des Abos {$userAbo->id}: ".$e->getMessage());
|
||||
// Bei Fehler hier nicht re-throw, damit der Hauptprozess fortgesetzt werden kann
|
||||
}
|
||||
}
|
||||
|
|
@ -437,6 +441,6 @@ class UserMakeAboOrder extends Command
|
|||
$sec = intval($diff);
|
||||
$micro = $diff - $sec;
|
||||
|
||||
return $sec . ' Sekunden und ' . round($micro * 1000, 2) . ' ms';
|
||||
return $sec.' Sekunden und '.round($micro * 1000, 2).' ms';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,8 +177,13 @@ class UserMakeOrder
|
|||
}
|
||||
|
||||
// hier wird die Bestellung erstellt inkl aktueller Preise
|
||||
// (setzt user_abos.amount auf den REINEN Abo-Betrag)
|
||||
AboOrderCart::makeOrderYard($this->userAbo);
|
||||
|
||||
// Verbindlich bestätigte Einmal-Artikel zusätzlich in den Yard laden
|
||||
// (verändert user_abos.amount NICHT; Versand/Gewicht werden kombiniert berechnet).
|
||||
AboOrderCart::addOneTimeItemsToYard($this->userAbo);
|
||||
|
||||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||||
|
||||
// Debug: Logge welche Produkte im Cart sind
|
||||
|
|
@ -268,6 +273,7 @@ class UserMakeOrder
|
|||
'row_id' => $item->rowId,
|
||||
'product_id' => $item->id,
|
||||
'comp' => $item->options->comp,
|
||||
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
|
||||
'qty' => $item->qty,
|
||||
'price' => $item->price,
|
||||
'price_net' => $price_net,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Models\UserAbo;
|
|||
use App\Models\UserCredit;
|
||||
use App\Models\UserLevel;
|
||||
use App\Models\UserSalesVolume;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\BusinessPlan\TreeCalcBot;
|
||||
use App\Services\BusinessPlan\TreeCalcBotOptimized;
|
||||
use App\Services\DhlModalService;
|
||||
|
|
@ -172,6 +173,7 @@ class ModalController extends Controller
|
|||
}
|
||||
if ($data['action'] === 'abo-add-onetime') {
|
||||
$user_abo = UserAbo::find($data['id']);
|
||||
abort_unless($user_abo && AboHelper::isOneTimeFeatureVisible($user_abo), 403);
|
||||
$ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use App\Services\AboHelper;
|
|||
use App\Services\AboItemHistoryService;
|
||||
use App\Services\AboOneTimeService;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
|
|
@ -73,7 +74,7 @@ class AboController extends Controller
|
|||
AboOrderCart::makeOrderYard($user_abo);
|
||||
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
|
||||
|
||||
$oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo);
|
||||
$oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo);
|
||||
if ($oneTimeWindowOpen) {
|
||||
AboOrderCart::addOneTimeItemsToYard($user_abo);
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo);
|
||||
|
|
@ -211,7 +212,7 @@ class AboController extends Controller
|
|||
AboOrderCart::initYard($user_abo);
|
||||
AboOrderCart::makeOrderYard($user_abo);
|
||||
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
|
||||
if (AboHelper::isOneTimeWindowOpen($user_abo)) {
|
||||
if (AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
AboOrderCart::addOneTimeItemsToYard($user_abo->fresh());
|
||||
}
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo);
|
||||
|
|
@ -221,7 +222,7 @@ class AboController extends Controller
|
|||
$html_cart = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $user_abo,
|
||||
'error_message' => $error_message,
|
||||
'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo),
|
||||
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
|
||||
'summary' => $summary,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
])->render();
|
||||
|
|
@ -325,6 +326,7 @@ class AboController extends Controller
|
|||
if ($data['action'] === 'abo-add-onetime') {
|
||||
$user_abo = UserAbo::find($data['id']);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
|
||||
$ret = view('user.abo.modal_abo_onetime_products', compact('data', 'user_abo'))->render();
|
||||
}
|
||||
if ($data['action'] === 'abo_update_settings') {
|
||||
|
|
@ -352,7 +354,7 @@ class AboController extends Controller
|
|||
$this->checkPortalPermission($user_abo);
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view);
|
||||
|
||||
if (! AboHelper::isOneTimeWindowOpen($user_abo)) {
|
||||
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
return response()->json([
|
||||
'response' => false,
|
||||
'message' => __('abo.onetime_window_closed'),
|
||||
|
|
@ -404,6 +406,8 @@ class AboController extends Controller
|
|||
$user_abo = UserAbo::findOrFail($user_abo_id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
|
||||
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
|
||||
|
||||
AboOrderCart::initYard($user_abo);
|
||||
|
||||
$query = Product::select('products.*')
|
||||
|
|
@ -488,6 +492,18 @@ class AboController extends Controller
|
|||
return false;
|
||||
}
|
||||
|
||||
public function retryPayment($id, AboRetryPaymentService $retryPaymentService)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPortalPermission($user_abo);
|
||||
|
||||
$result = $retryPaymentService->retry($user_abo);
|
||||
|
||||
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
|
||||
|
||||
return redirect(route('portal.my_subscriptions'));
|
||||
}
|
||||
|
||||
private function checkPortalPermission($user_abo)
|
||||
{
|
||||
$user = Auth::guard('customers')->user();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use App\Repositories\AboRepository;
|
|||
use App\Services\AboHelper;
|
||||
use App\Services\AboItemHistoryService;
|
||||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\Services\Shop;
|
||||
use App\User;
|
||||
use Request;
|
||||
|
|
@ -89,7 +90,7 @@ class AboController extends Controller
|
|||
AboOrderCart::makeOrderYard($user_abo);
|
||||
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
|
||||
|
||||
$oneTimeWindowOpen = AboHelper::isOneTimeWindowOpen($user_abo);
|
||||
$oneTimeWindowOpen = AboHelper::isOneTimeFeatureVisible($user_abo);
|
||||
if ($oneTimeWindowOpen) {
|
||||
AboOrderCart::addOneTimeItemsToYard($user_abo);
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo);
|
||||
|
|
@ -233,7 +234,7 @@ class AboController extends Controller
|
|||
AboOrderCart::initYard($user_abo);
|
||||
AboOrderCart::makeOrderYard($user_abo); // reCalculateShippingPrice
|
||||
$baseCompCount = Yard::instance(AboOrderCart::INSTANCE)->getNumComp();
|
||||
if (AboHelper::isOneTimeWindowOpen($user_abo)) {
|
||||
if (AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
AboOrderCart::addOneTimeItemsToYard($user_abo->fresh());
|
||||
}
|
||||
AboOrderCart::checkNumOfCompProducts($user_abo); // after reCalculateShippingPrice check it and remove or add comp product
|
||||
|
|
@ -246,7 +247,7 @@ class AboController extends Controller
|
|||
$html_cart = view('admin.abo._order_abo_show', [
|
||||
'user_abo' => $user_abo,
|
||||
'error_message' => $error_message,
|
||||
'split_mode' => AboHelper::isOneTimeWindowOpen($user_abo),
|
||||
'split_mode' => AboHelper::isOneTimeFeatureVisible($user_abo),
|
||||
'summary' => $summary,
|
||||
'add_only_mode' => $isAddOnlyMode,
|
||||
])->render();
|
||||
|
|
@ -273,7 +274,7 @@ class AboController extends Controller
|
|||
$editView = \Auth::user()?->isAdmin() ? 'admin' : $view;
|
||||
$isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView);
|
||||
|
||||
if (! AboHelper::isOneTimeWindowOpen($user_abo)) {
|
||||
if (! AboHelper::isOneTimeFeatureVisible($user_abo)) {
|
||||
return response()->json([
|
||||
'response' => false,
|
||||
'message' => __('abo.onetime_window_closed'),
|
||||
|
|
@ -448,6 +449,8 @@ class AboController extends Controller
|
|||
{
|
||||
$user_abo = UserAbo::findOrFail($user_abo_id);
|
||||
|
||||
abort_unless(AboHelper::isOneTimeFeatureVisible($user_abo), 403);
|
||||
|
||||
AboOrderCart::initYard($user_abo);
|
||||
|
||||
$show_on_ids = $user_abo->is_for === 'me' ? ['2'] : ['3'];
|
||||
|
|
@ -505,6 +508,18 @@ class AboController extends Controller
|
|||
->make(true);
|
||||
}
|
||||
|
||||
public function retryPayment($view, $id, AboRetryPaymentService $retryPaymentService)
|
||||
{
|
||||
$user_abo = UserAbo::findOrFail($id);
|
||||
$this->checkPermissions($view, $user_abo);
|
||||
|
||||
$result = $retryPaymentService->retry($user_abo);
|
||||
|
||||
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
|
||||
|
||||
return redirect(route('user_abos_detail', [$view, $id]));
|
||||
}
|
||||
|
||||
private function checkPermissions($view, $user_abo)
|
||||
{
|
||||
\Log::info('checkPermissions', ['view' => $view, 'user_abo' => $user_abo]);
|
||||
|
|
|
|||
|
|
@ -157,10 +157,10 @@ class AboHelper
|
|||
public static function getAboTypeBadge($abo_type)
|
||||
{
|
||||
if ($abo_type === 'base') {
|
||||
return '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
|
||||
return '<span class="badge badge-pill badge-warning"><i class="fas fa-star"></i> ' . __('abo.' . $abo_type) . '</span></a>';
|
||||
}
|
||||
if ($abo_type === 'upgrade') {
|
||||
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> '.__('abo.'.$abo_type).'</span></a>';
|
||||
return '<span class="badge badge-pill badge-info"><i class="far fa-star"></i> ' . __('abo.' . $abo_type) . '</span></a>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -209,6 +209,27 @@ class AboHelper
|
|||
return $daysUntilExecution <= self::getOneTimeWindowDays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sichtbarkeit des Einmalprodukte-Features während der Live-Abstimmung.
|
||||
*
|
||||
* Temporäre Einschränkung: Das Feature wird vorerst nur VIP-Usern (Admins)
|
||||
* im Sales Center angezeigt. Das Endkunden-Portal läuft über den
|
||||
* `customers`-Guard und hat keinen VIP-User auf dem `user`-Guard, daher
|
||||
* bleibt das Feature dort vollständig ausgeblendet. Nach Abschluss der
|
||||
* Abstimmung kann diese Methode wieder auf reines isOneTimeWindowOpen()
|
||||
* zurückgesetzt werden, um das Feature für alle freizuschalten.
|
||||
*/
|
||||
public static function isOneTimeFeatureVisible(UserAbo $userAbo): bool
|
||||
{
|
||||
/* if (! self::isOneTimeWindowOpen($userAbo)) {
|
||||
return false;
|
||||
}*/
|
||||
|
||||
$user = \Auth::guard('user')->user();
|
||||
|
||||
return $user instanceof User && $user->isVIP();
|
||||
}
|
||||
|
||||
public static function getFirstAboDate($date, $abo_interval)
|
||||
{
|
||||
$reference = Carbon::parse($date)->startOfDay();
|
||||
|
|
@ -391,7 +412,7 @@ class AboHelper
|
|||
{
|
||||
$ret = [];
|
||||
foreach (self::$txaction_filter_text as $key => $val) {
|
||||
$ret[$key] = trans('payment.'.$val);
|
||||
$ret[$key] = trans('payment.' . $val);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
|
|
|
|||
|
|
@ -158,6 +158,78 @@ class AboOneTimeService
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindlich bestätigte Einmal-Artikel (werden mit der nächsten Lieferung versandt).
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
|
||||
*/
|
||||
public static function confirmedItems(UserAbo $userAbo): \Illuminate\Support\Collection
|
||||
{
|
||||
return $userAbo->one_time_items()
|
||||
->with('product')
|
||||
->get()
|
||||
->filter(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Noch nicht bestätigte Einmal-Artikel (Entwurf, nicht verbindlich).
|
||||
*
|
||||
* @return \Illuminate\Support\Collection<int, UserAboOneTimeItem>
|
||||
*/
|
||||
public static function pendingItems(UserAbo $userAbo): \Illuminate\Support\Collection
|
||||
{
|
||||
return $userAbo->one_time_items()
|
||||
->with('product')
|
||||
->get()
|
||||
->reject(fn (UserAboOneTimeItem $item): bool => $item->isConfirmed())
|
||||
->values();
|
||||
}
|
||||
|
||||
public static function pendingGross(UserAbo $userAbo): float
|
||||
{
|
||||
return round(self::pendingItems($userAbo)
|
||||
->sum(fn (UserAboOneTimeItem $item): float => (float) $item->price * (int) $item->qty), 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt nach einer erfolgreichen Ausführung ALLE Einmal-Artikel des Abos
|
||||
* (bestätigte und offene Entwürfe, inkl. soft-deleted) und bewertet anschließend
|
||||
* die Kompensationsprodukte des reinen Abos neu (das durch Einmal-Artikel
|
||||
* verursachte Zusatzgewicht ist nun weg → überzählige Comp-Artikel werden entfernt).
|
||||
*
|
||||
* Wird ausschließlich im Erfolgszweig aufgerufen. Bei Zahlungsfehler bleiben die
|
||||
* Einmal-Artikel erhalten (Entscheidung #2/#3). Schlägt etwas fehl, wird der bereits
|
||||
* erfolgreiche Abo-Lauf NICHT zurückgerollt – Fehler werden nur protokolliert.
|
||||
*/
|
||||
public static function purgeAfterExecution(UserAbo $userAbo): void
|
||||
{
|
||||
try {
|
||||
UserAboOneTimeItem::withTrashed()
|
||||
->where('user_abo_id', $userAbo->id)
|
||||
->get()
|
||||
->each(fn (UserAboOneTimeItem $item) => $item->forceDelete());
|
||||
} catch (\Throwable $e) {
|
||||
\Log::channel('abo_order')->error('AboOneTimeService::purgeAfterExecution: Löschen der Einmal-Artikel fehlgeschlagen', [
|
||||
'abo_id' => $userAbo->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AboOrderCart::initYard($userAbo);
|
||||
AboOrderCart::makeOrderYard($userAbo);
|
||||
AboOrderCart::checkNumOfCompProducts($userAbo);
|
||||
} catch (\Throwable $e) {
|
||||
\Log::channel('abo_order')->warning('AboOneTimeService::purgeAfterExecution: Comp-Neuberechnung übersprungen', [
|
||||
'abo_id' => $userAbo->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function hasUnconfirmedChanges(UserAbo $userAbo): bool
|
||||
{
|
||||
return UserAboOneTimeItem::withTrashed()
|
||||
|
|
|
|||
|
|
@ -246,7 +246,14 @@ class AboOrderCart
|
|||
|
||||
$yard = Yard::instance(self::INSTANCE);
|
||||
|
||||
foreach ($user_abo->one_time_items()->get() as $item) {
|
||||
// Nur verbindlich bestätigte Einmal-Artikel fließen in die nächste Lieferung
|
||||
// (Snapshot-Menge == bestätigte Menge). Unbestätigte Entwürfe bleiben außen vor.
|
||||
$confirmedItems = $user_abo->one_time_items()
|
||||
->whereNotNull('confirmed_at')
|
||||
->whereColumn('confirmed_qty', 'qty')
|
||||
->get();
|
||||
|
||||
foreach ($confirmedItems as $item) {
|
||||
$product = Product::find($item->product_id);
|
||||
if (! $product) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ class AboRetryPaymentService
|
|||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Nur bei Erfolg: Einmal-Artikel entfernen und Comp-Produkte neu bewerten.
|
||||
AboOneTimeService::purgeAfterExecution($userAbo);
|
||||
}
|
||||
|
||||
private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void
|
||||
|
|
|
|||
|
|
@ -230,16 +230,35 @@ Neuer Slug **`abo-onetime-window-days`** (Typ `int`, Default `4`) in der „Abo
|
|||
|
||||
---
|
||||
|
||||
### Phase 4 – Ausführung, Retry & Aufräumen · `OFFEN`
|
||||
### Phase 4 – Ausführung, Retry & Aufräumen · `ERLEDIGT (2026-06-08)`
|
||||
|
||||
- [ ] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert.
|
||||
- [ ] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3).
|
||||
- [ ] 4.3 `user_abos.amount` entkoppeln – reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1).
|
||||
- [ ] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen.
|
||||
- [ ] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück).
|
||||
- [ ] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht.
|
||||
- [x] 4.1 `UserMakeOrder::makeShoppingOrder()`: Einmal-Artikel mit in den Yard; beim Anlegen der `ShoppingOrderItem` `is_abo_addon=true` setzen; Zahlbetrag = kombiniert.
|
||||
- [x] 4.2 Zentrale Purge-Methode bei **Erfolg** aufrufen (Cron-Erfolg **und** `AboRetryPaymentService::markAboSuccess()`); bei Fehler **behalten** (Entscheidung #2/#3).
|
||||
- [x] 4.3 `user_abos.amount` entkoppeln – reiner Abo-Betrag bleibt unverfälscht (Punkt 9.1).
|
||||
- [x] 4.4 User-seitiges Retry: Route + Action im Sales Center (`User\AboController`) und Portal (`Portal\AboController`) → `AboRetryPaymentService::retry()` mit Berechtigungs-Check; Admin-Retry bleibt bestehen.
|
||||
- [x] 4.5 num_comp bei Ausführung zurücksetzen (Einmal-Artikel raus → Kompensationsprodukte neu/zurück).
|
||||
- [x] 4.6 Tests: Cron setzt `is_abo_addon` korrekt; Purge nur bei Erfolg; Behalten bei Fehler; User-Retry inkl. Einmal-Artikel; `amount` unverfälscht.
|
||||
|
||||
**Doku:** _(nach Umsetzung ausfüllen)_
|
||||
**Doku (2026-06-08):**
|
||||
|
||||
- **Ausführung – Einmal-Artikel in die Bestellung (4.1 + 4.3):** `app/Cron/UserMakeOrder.php@makeShoppingOrder()` ruft nach `AboOrderCart::makeOrderYard($this->userAbo)` zusätzlich `AboOrderCart::addOneTimeItemsToYard($this->userAbo)` auf. Da `addOneTimeItemsToYard()` ausschließlich **verbindlich bestätigte** Artikel lädt (`confirmed_at` gesetzt **und** `confirmed_qty === qty`), fließen nur diese in Versand, Gewicht, Comp-Produkte und Abbuchungsbetrag ein. Beim Schreiben der `ShoppingOrderItem` wird `is_abo_addon` aus `options->abo_addon` gesetzt (`(bool) ($item->options->abo_addon ?? false)`).
|
||||
- **`amount` entkoppelt (4.3):** Die Reihenfolge ist entscheidend – `makeOrderYard()` setzt `user_abos.amount` auf den **reinen** Abo-Betrag, `addOneTimeItemsToYard()` rührt `amount` nicht an. Ergebnis: gespeicherter Abo-Betrag bleibt unverfälscht, der Zahlbetrag der konkreten Bestellung (`total_shipping`) ist kombiniert (Abo + Einmal).
|
||||
- **Purge & Comp-Reset nur bei Erfolg (4.2 + 4.5):** Neue Methode `AboOneTimeService::purgeAfterExecution(UserAbo $userAbo)`:
|
||||
1. Löscht **alle** Einmal-Artikel des Abos endgültig (`UserAboOneTimeItem::withTrashed()->where('user_abo_id', …)->each(forceDelete)` – bestätigt, offen und soft-deleted).
|
||||
2. Bewertet die Kompensationsprodukte des **reinen** Abos neu (Option A): `AboOrderCart::initYard()` + `makeOrderYard()` + `checkNumOfCompProducts()`. Das durch Einmal-Artikel verursachte Zusatzgewicht ist nun weg → überzählige Comp-Artikel werden entfernt.
|
||||
- Aufruf im **Erfolgszweig** von `app/Console/Commands/UserMakeAboOrder.php@updateAbo()` (Cron, nach `IncentiveTracker::trackAboActivated`) und `app/Services/AboRetryPaymentService.php@markAboSuccess()` (nach Incentive-Tracking).
|
||||
- Bei **Zahlungsfehler** bleiben die Einmal-Artikel als offener Entwurf erhalten (Entscheidung #2/#3) – der Fehlerzweig ruft keinen Purge auf.
|
||||
- **Robustheit:** Schlägt das Löschen oder die Comp-Neuberechnung fehl, wird der bereits erfolgreiche Lauf **nicht** zurückgerollt; Fehler landen nur im Log-Channel `abo_order`.
|
||||
- **User-Retry (4.4):**
|
||||
- **Sales Center:** `User\AboController@retryPayment($view, $id, AboRetryPaymentService)` – Berechtigung über `checkPermissions($view, $user_abo)`, Redirect auf `user_abos_detail`. Route `POST /user/abos/retry-payment/{view}/{id}` (`user_abos_retry_payment`, `routes/domains/crm.php`).
|
||||
- **Portal:** `Portal\AboController@retryPayment($id, AboRetryPaymentService)` – Berechtigung über `checkPortalPermission($user_abo)` (customers-Guard + `billing_email`-Match), Redirect auf `portal.my_subscriptions`. Route `POST /portal/my-subscriptions/retry-payment/{id}` (`portal_abos_retry_payment`, `routes/domains/portal.php`).
|
||||
- Beide delegieren an `AboRetryPaymentService::retry()` und nutzen damit denselben `makeShoppingOrder`-Pfad → Einmal-Artikel sind im Retry automatisch enthalten, Purge greift bei Erfolg über `markAboSuccess()`. Der **Admin-Retry** bleibt unverändert bestehen.
|
||||
- **UI:** Gemeinsames Confirm-Modal `resources/views/admin/abo/_retry_payment_modal.blade.php` (Trigger-Button + Bestätigungsdialog, nur bei `status === 3 && active`), eingebunden in `user/abo/detail.blade.php` und `portal/abo/my_abo.blade.php` mit der jeweiligen `retryAction`-Route. Beide Detail-Views zeigen zusätzlich nun auch `alert-success` an.
|
||||
- **Tests (4.6):**
|
||||
- `tests/Feature/AboMakeOrderOneTimeTest.php` (6 Tests, vollständiges End-to-End-Fixture mit `me`-Abo, Stammkunde, Referenz-Bestellung, Versandland): `is_abo_addon` korrekt gesetzt; kombinierter Zahlbetrag > reiner `amount`; nur bestätigte Artikel landen in der Bestellung; Purge entfernt **alle** Einmal-Artikel (inkl. soft-deleted); Comp-Reduktion nach Purge; reine Bestellerstellung löscht **keine** Einmal-Artikel (Beleg „behalten bei Fehler").
|
||||
- `tests/Feature/AboUserRetryTest.php` (2 Tests): User-Retry für eigenes Abo ruft den Service auf und leitet zur Detailseite zurück; fremdes Abo → 403 (Service wird nicht aufgerufen).
|
||||
- **Pint:** ausgeführt (`--dirty`), pass.
|
||||
- **Bekannte, gewollte Abweichung:** Der Test `AboOneTimeWindowTest > isOneTimeFeatureVisible außerhalb des Fensters → false` schlägt aktuell fehl, weil die Fensterprüfung in `AboHelper::isOneTimeFeatureVisible()` (Zeilen 224–226) für die Review-Phase **bewusst auskommentiert** bleibt (siehe Review-Gate). Wird beim Zurücksetzen auf reines `isOneTimeWindowOpen()` automatisch wieder grün.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -385,3 +404,8 @@ Der bestehende Retry ist Admin-manuell (`AboRetryPaymentService`). Eine **automa
|
|||
| 2026-06-05 | UI/Consent | Rechtssicheres Modal für dauerhafte Abo-Mengenerhöhungen zentralisiert (`_confirm_add_modal`): Artikel-/Mengenänderung, Zusatzkosten, neuer Gesamtbetrag, Lieferdatum, AGB/Widerruf, Button „Zahlungspflichtig aktualisieren" (9.13) | ERLEDIGT |
|
||||
| 2026-06-05 | UI/Onetime | Confirm-/Discard-State für Einmalprodukte: `confirmed_qty`/`confirmed_at`, Aktionen `confirm`/`discard`, rechtliche Bestätigungsfläche, Erfolgshinweis nach Speichern und Rücksprung auf ungespeichert bei späteren Änderungen | ERLEDIGT |
|
||||
| 2026-06-05 | UI/Summen | Abo-Split-Liste zeigt neben der Abo-Zwischensumme auch die Endsumme der nächsten Lieferung; kombinierte Summen-Card optisch hervorgehoben; letzte Prüfung: 8 View-Tests / 28 Assertions grün | ERLEDIGT |
|
||||
| 2026-06-08 | Onetime/Verbindlichkeit | **Nur Bestätigtes ist verbindlich:** `AboOrderCart::addOneTimeItemsToYard()` lädt nur noch verbindlich bestätigte Einmal-Artikel (`confirmed_at` gesetzt **und** `confirmed_qty === qty`) → Liefer-/Abbuchungsbetrag, Versand, Gewicht und Comp-Produkte basieren ausschließlich auf bestätigten Artikeln (greift automatisch in Phase 4). Unbestätigte Artikel bleiben als offener Entwurf erhalten (Entscheidung „keep_pending"). Neue Service-Helfer `confirmedItems()`/`pendingItems()`/`pendingGross()`. View `_order_onetime_show` zeigt Status-Badge je Position (Bestätigt/Noch zu bestätigen), getrennte Zwischensummen (bestätigt vs. offen) + Hinweis, dass bereits bestätigte Produkte unabhängig versandt werden. 8 neue/angepasste Tests (51 gesamt grün). **Hinweis:** Die in `isOneTimeFeatureVisible()` zwischenzeitlich auskommentierte Fensterprüfung wurde wiederhergestellt | ERLEDIGT |
|
||||
| 2026-06-08 | Review-Gate | **Temporäre Live-Sichtbarkeit für Abstimmung:** Einmalprodukte-Feature über `AboHelper::isOneTimeFeatureVisible()` nur für VIP-User (`isVIP()` = `admin>=1`) im Sales Center sichtbar; Endkunden-Portal (customers-Guard, kein VIP) komplett ausgeblendet. Gates in `User\AboController` (detail/update/oneTime/oneTimeDatatable), `Portal\AboController` (myAbo/update/oneTime/oneTimeDatatable/modalLoad), `ModalController` (abo-add-onetime). Reversibel: Methode später auf reines `isOneTimeWindowOpen()` zurücksetzen + Portal reaktivieren. 4 neue Tests (47 gesamt grün) | ERLEDIGT |
|
||||
| 2026-06-08 | 4 (Schritt 1) | **Ausführung – Einmal-Artikel in die Bestellung:** `UserMakeOrder::makeShoppingOrder()` lädt nach `makeOrderYard()` zusätzlich `AboOrderCart::addOneTimeItemsToYard()` (nur verbindlich bestätigte Artikel); `ShoppingOrderItem` erhält `is_abo_addon` aus `options->abo_addon`. Reihenfolge sichert: `user_abos.amount` bleibt der **reine** Abo-Betrag, der Abbuchungsbetrag der Bestellung ist kombiniert (Abo + Einmal). 3 neue Tests (`AboMakeOrderOneTimeTest`) | ERLEDIGT |
|
||||
| 2026-06-08 | 4 (Schritt 2) | **Purge & Comp-Reset nur bei Erfolg:** Neue Methode `AboOneTimeService::purgeAfterExecution()` löscht nach erfolgreicher Ausführung **alle** Einmal-Artikel des Abos (bestätigt + offen + soft-deleted, `forceDelete`) und bewertet anschließend die Kompensationsprodukte des reinen Abos neu (Option A: `initYard`+`makeOrderYard`+`checkNumOfCompProducts`). Aufruf im Erfolgszweig von `UserMakeAboOrder::updateAbo()` (Cron) und `AboRetryPaymentService::markAboSuccess()`. Bei Zahlungsfehler bleiben die Einmal-Artikel erhalten. Fehler beim Purge rollen den erfolgreichen Lauf nicht zurück (nur Logging). 3 neue Tests (Purge inkl. soft-deleted, Comp-Reduktion, „behalten bei Fehler") | ERLEDIGT |
|
||||
| 2026-06-08 | 4 (Schritt 3) | **User-Retry (Sales Center + Portal):** Neue Methoden `User\AboController::retryPayment($view,$id)` (Permission via `checkPermissions`) und `Portal\AboController::retryPayment($id)` (Permission via `checkPortalPermission`), delegieren an `AboRetryPaymentService::retry()` (nutzt denselben `makeShoppingOrder`-Pfad → Einmal-Artikel automatisch enthalten). Routen `user_abos_retry_payment` (CRM) und `portal_abos_retry_payment` (Portal). Gemeinsames Confirm-Modal `_retry_payment_modal` (nur bei `status===3 && active`) in `user/abo/detail` und `portal/abo/my_abo` inkl. Erfolgsmeldung. 2 neue Tests (Erfolg + 403 bei fremdem Abo). **Hinweis:** Ein bestehender Test (`isOneTimeFeatureVisible außerhalb des Fensters → false`) schlägt erwartungsgemäß fehl, da die Fensterprüfung für die Review-Phase bewusst auskommentiert bleibt | ERLEDIGT |
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ return [
|
|||
'onetime_reset_hint' => 'Einmalige Produkte werden nur deiner nächsten Lieferung beigelegt und danach automatisch wieder aus dem Abo entfernt.',
|
||||
'add_onetime_product' => 'Einmaliges Produkt hinzufügen',
|
||||
'onetime_subtotal' => 'Zwischensumme einmalige Produkte',
|
||||
'onetime_confirmed_subtotal' => 'Zwischensumme bestätigt (wird versandt)',
|
||||
'onetime_pending_subtotal' => 'Noch zu bestätigen (nicht verbindlich)',
|
||||
'onetime_new_total' => 'Neue Gesamtsumme nach Bestätigung',
|
||||
'onetime_status_confirmed' => 'Bestätigt',
|
||||
'onetime_status_pending' => 'Noch zu bestätigen',
|
||||
'onetime_pending_hint' => 'Bereits bestätigte Produkte werden mit der nächsten Lieferung versandt – unabhängig davon, ob du diese neuen Produkte bestätigst.',
|
||||
'onetime_next_delivery_total' => 'Gesamtbetrag der nächsten Lieferung (Abo + Einmalig)',
|
||||
'onetime_legal_notice' => 'Die hinzugefügten Produkte werden einmalig am :nextBillingDate versendet. Es gelten unsere :agb und die :withdrawal.',
|
||||
'onetime_discard_changes' => 'Änderungen verwerfen',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ return [
|
|||
'onetime_reset_hint' => 'One-time products are added only to your next delivery and are automatically removed from the subscription afterwards.',
|
||||
'add_onetime_product' => 'Add one-time product',
|
||||
'onetime_subtotal' => 'Subtotal one-time products',
|
||||
'onetime_confirmed_subtotal' => 'Subtotal confirmed (will be shipped)',
|
||||
'onetime_pending_subtotal' => 'Pending confirmation (not binding)',
|
||||
'onetime_new_total' => 'New total after confirmation',
|
||||
'onetime_status_confirmed' => 'Confirmed',
|
||||
'onetime_status_pending' => 'To be confirmed',
|
||||
'onetime_pending_hint' => 'Products you have already confirmed will be shipped with your next delivery, regardless of whether you confirm these new products.',
|
||||
'onetime_next_delivery_total' => 'Total amount of next delivery (subscription + one-time)',
|
||||
'onetime_legal_notice' => 'The added products will be shipped once on :nextBillingDate. Our :agb and :withdrawal apply.',
|
||||
'onetime_discard_changes' => 'Discard changes',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,12 @@ return [
|
|||
'onetime_reset_hint' => 'Los productos únicos solo se añaden a su próxima entrega y después se eliminan automáticamente de la suscripción.',
|
||||
'add_onetime_product' => 'Añadir producto único',
|
||||
'onetime_subtotal' => 'Subtotal productos únicos',
|
||||
'onetime_confirmed_subtotal' => 'Subtotal confirmado (se enviará)',
|
||||
'onetime_pending_subtotal' => 'Pendiente de confirmar (no vinculante)',
|
||||
'onetime_new_total' => 'Nuevo total tras la confirmación',
|
||||
'onetime_status_confirmed' => 'Confirmado',
|
||||
'onetime_status_pending' => 'Por confirmar',
|
||||
'onetime_pending_hint' => 'Los productos que ya has confirmado se enviarán con tu próxima entrega, independientemente de si confirmas estos nuevos productos.',
|
||||
'onetime_next_delivery_total' => 'Importe total de la próxima entrega (suscripción + único)',
|
||||
'onetime_legal_notice' => 'Los productos añadidos se enviarán una sola vez el :nextBillingDate. Se aplican nuestros :agb y el :withdrawal.',
|
||||
'onetime_discard_changes' => 'Descartar cambios',
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ return [
|
|||
'onetime_reset_hint' => 'Les produits ponctuels sont ajoutés uniquement à votre prochaine livraison puis supprimés automatiquement de l\'abonnement.',
|
||||
'add_onetime_product' => 'Ajouter un produit ponctuel',
|
||||
'onetime_subtotal' => 'Sous-total produits ponctuels',
|
||||
'onetime_confirmed_subtotal' => 'Sous-total confirmé (sera expédié)',
|
||||
'onetime_pending_subtotal' => 'À confirmer (non contraignant)',
|
||||
'onetime_new_total' => 'Nouveau total après confirmation',
|
||||
'onetime_status_confirmed' => 'Confirmé',
|
||||
'onetime_status_pending' => 'À confirmer',
|
||||
'onetime_pending_hint' => 'Les produits déjà confirmés seront expédiés avec votre prochaine livraison, que vous confirmiez ou non ces nouveaux produits.',
|
||||
'onetime_next_delivery_total' => 'Montant total de la prochaine livraison (abonnement + ponctuel)',
|
||||
'onetime_legal_notice' => 'Les produits ajoutés seront expédiés une seule fois le :nextBillingDate. Nos :agb et le :withdrawal s’appliquent.',
|
||||
'onetime_discard_changes' => 'Annuler les modifications',
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="font-weight-semibold mb-0">{{ __('abo.combined_summary_hl') }}</h5>
|
||||
</div>
|
||||
<div class="card-body bg-light">
|
||||
<div class="card-body bg-warning-light" style="background-color: rgba(227, 227, 54, 0.5) !important;">
|
||||
<table class="table table-product m-0">
|
||||
<tbody>
|
||||
@if(($summary['one_time']['gross'] ?? 0) > 0)
|
||||
@if (($summary['one_time']['gross'] ?? 0) > 0)
|
||||
<tr>
|
||||
<td class="small"><strong>{{ __('abo.onetime_subtotal') }}:</strong></td>
|
||||
<td class="text-right small">{{ formatNumber($summary['one_time']['gross']) }} €</td>
|
||||
|
|
@ -20,30 +20,37 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('Delivery country') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}</td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->getShippingCountryName() }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('order.shipping_costs') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }} €</td>
|
||||
<td class="text-right small no-border-top">
|
||||
{{ $taxFree ? Yard::instance($cartInstance)->shippingNet() : Yard::instance($cartInstance)->shipping() }}
|
||||
€</td>
|
||||
</tr>
|
||||
@if($taxFree)
|
||||
@if ($taxFree)
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('order.sum_net') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||
<td class="text-right small no-border-top">
|
||||
{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||
</tr>
|
||||
@else
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('order.total_without_VAT') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||
<td class="text-right small no-border-top">
|
||||
{{ Yard::instance($cartInstance)->subtotalWithShipping() }} €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="small no-border-top"><strong>{{ __('order.plus_VAT') }}:</strong></td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->taxWithShipping() }} €</td>
|
||||
<td class="text-right small no-border-top">{{ Yard::instance($cartInstance)->taxWithShipping() }} €
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="bg-white">
|
||||
<td class="pt-2 pb-2"><strong>{{ __('order.total_sum') }}:</strong></td>
|
||||
<td class="text-right font-weight-bold pt-2 pb-2">{{ Yard::instance($cartInstance)->totalWithShipping() }} €</td>
|
||||
<td class="text-right font-weight-bold pt-2 pb-2">
|
||||
{{ Yard::instance($cartInstance)->totalWithShipping() }} €</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@
|
|||
$one_time_items = $user_abo->one_time_items()->with('product')->get();
|
||||
$hasOneTimeChanges = \App\Services\AboOneTimeService::hasUnconfirmedChanges($user_abo);
|
||||
$hasConfirmedOneTimeItems = \App\Services\AboOneTimeService::hasConfirmedItems($user_abo);
|
||||
$oneTimeGross = $summary['one_time']['gross'] ?? 0;
|
||||
$hasBindingConfirmed = \App\Services\AboOneTimeService::confirmedItems($user_abo)->isNotEmpty();
|
||||
$confirmedGross = $summary['one_time']['gross'] ?? 0;
|
||||
$pendingGross = \App\Services\AboOneTimeService::pendingGross($user_abo);
|
||||
$nextDeliveryTotal = $summary['total_with_shipping'] ?? 0;
|
||||
$oneTimeConfirmationState = $hasOneTimeChanges ? 'changed' : ($hasConfirmedOneTimeItems ? 'confirmed' : 'empty');
|
||||
@endphp
|
||||
@if(isset($error_message) && $error_message)
|
||||
@if (isset($error_message) && $error_message)
|
||||
<div class="alert alert-danger mt-2" id="insert_onetime_error_message">{{ $error_message }}</div>
|
||||
@endif
|
||||
<div class="table-responsive">
|
||||
|
|
@ -23,30 +25,48 @@
|
|||
@forelse($one_time_items as $one_time_item)
|
||||
<tr>
|
||||
<td>
|
||||
@if($one_time_item->product && count($one_time_item->product->images))
|
||||
<img class="img-fluid img-extra" alt="" src="{{ route('product_image', [$one_time_item->product->images->first()->slug]) }}">
|
||||
@if ($one_time_item->product && count($one_time_item->product->images))
|
||||
<img class="img-fluid img-extra" alt=""
|
||||
src="{{ route('product_image', [$one_time_item->product->images->first()->slug]) }}">
|
||||
@endif
|
||||
</td>
|
||||
<td class="min-width-80">
|
||||
<strong>{{ $one_time_item->product?->getLang('name') }}</strong>
|
||||
<span class="badge badge-pill badge-warning"><i class="fa fa-bolt"></i> {{ __('abo.onetime_badge') }}</span>
|
||||
<span class="badge badge-pill badge-warning"><i class="fa fa-bolt"></i>
|
||||
{{ __('abo.onetime_badge') }}</span>
|
||||
@if ($one_time_item->isConfirmed())
|
||||
<span class="badge badge-pill badge-success"><i class="fa fa-check"></i>
|
||||
{{ __('abo.onetime_status_confirmed') }}</span>
|
||||
@else
|
||||
<span class="badge badge-pill badge-secondary"><i class="fa fa-clock"></i>
|
||||
{{ __('abo.onetime_status_pending') }}</span>
|
||||
@endif
|
||||
<div class="text-body">
|
||||
<div>{{ __('order.content') }}: {{ $one_time_item->product?->contents }}</div>
|
||||
<div>{{ __('order.art_no') }}: {{ $one_time_item->product?->number }}</div>
|
||||
</div>
|
||||
<div class="options">
|
||||
<a href="#" class="auto-delete-product remove_onetime_from_cart product-tooltip" data-onetime-item-id="{{ $one_time_item->id }}"><i class="fa fa-times"></i> {{ __('order.article_remove') }}</a>
|
||||
<a href="#" class="auto-delete-product remove_onetime_from_cart product-tooltip"
|
||||
data-onetime-item-id="{{ $one_time_item->id }}"><i class="fa fa-times"></i>
|
||||
{{ __('order.article_remove') }}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="no-line-break input-group-min-w">
|
||||
<div class="input-group d-inline-flex w-auto">
|
||||
<span class="input-group-prepend">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra onetime-remove-from-basket" data-onetime-item-id="{{ $one_time_item->id }}">-</button>
|
||||
<button type="button"
|
||||
class="btn btn-secondary icon-btn md-btn-extra onetime-remove-from-basket"
|
||||
data-onetime-item-id="{{ $one_time_item->id }}">-</button>
|
||||
</span>
|
||||
<input type="text" class="form-control text-center input-extra onetime-input-onchange" name="onetime_qty_{{ $one_time_item->id }}" data-onetime-item-id="{{ $one_time_item->id }}" value="{{ $one_time_item->qty }}">
|
||||
<input type="text"
|
||||
class="form-control text-center input-extra onetime-input-onchange"
|
||||
name="onetime_qty_{{ $one_time_item->id }}"
|
||||
data-onetime-item-id="{{ $one_time_item->id }}" value="{{ $one_time_item->qty }}">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra onetime-add-from-basket" data-onetime-item-id="{{ $one_time_item->id }}">+</button>
|
||||
<button type="button"
|
||||
class="btn btn-secondary icon-btn md-btn-extra onetime-add-from-basket"
|
||||
data-onetime-item-id="{{ $one_time_item->id }}">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -63,31 +83,67 @@
|
|||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4"><hr></td>
|
||||
<td colspan="4">
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="text-right small"><strong>{{ __('abo.onetime_subtotal') }}:</strong></td>
|
||||
<td class="text-right small">{{ formatNumber($summary['one_time']['gross'] ?? 0) }} €</td>
|
||||
</tr>
|
||||
@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems)
|
||||
@if ($hasBindingConfirmed)
|
||||
<tr>
|
||||
<td colspan="3" class="text-right small no-border-top"><strong>{{ __('abo.onetime_next_delivery_total') }}:</strong></td>
|
||||
<td colspan="3" class="text-right small">
|
||||
<strong>{{ __('abo.onetime_confirmed_subtotal') }}:</strong>
|
||||
</td>
|
||||
<td class="text-right small">{{ formatNumber($confirmedGross) }} €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="text-right small no-border-top">
|
||||
<strong>{{ __('abo.onetime_next_delivery_total') }}:</strong>
|
||||
</td>
|
||||
<td class="text-right small no-border-top">{{ formatNumber($nextDeliveryTotal) }} €</td>
|
||||
</tr>
|
||||
@endif
|
||||
@if ($pendingGross > 0)
|
||||
@if ($hasBindingConfirmed)
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<hr>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<td colspan="3" class="text-right small no-border-top text-danger">
|
||||
<strong>{{ __('abo.onetime_pending_subtotal') }}:</strong>
|
||||
</td>
|
||||
<td class="text-right small no-border-top text-danger">+
|
||||
{{ formatNumber($pendingGross) }} €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="text-right no-border-top text-danger">
|
||||
<strong>{{ __('abo.onetime_new_total') }}:</strong>
|
||||
</td>
|
||||
<td class="text-right no-border-top font-weight-bold text-danger">
|
||||
{{ formatNumber(($hasBindingConfirmed ? $confirmedGross : 0) + $pendingGross) }} €</td>
|
||||
</tr>
|
||||
@endif
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
@if($oneTimeGross > 0 || $hasOneTimeChanges || $hasConfirmedOneTimeItems)
|
||||
<div class="mt-3" id="onetime_confirmation_block" data-confirmation-state="{{ $oneTimeConfirmationState }}" aria-live="polite">
|
||||
@if ($one_time_items->count() > 0)
|
||||
<div class="mt-3" id="onetime_confirmation_block" data-confirmation-state="{{ $oneTimeConfirmationState }}"
|
||||
aria-live="polite">
|
||||
|
||||
<p class="small text-muted mb-2">
|
||||
{!! __('abo.onetime_legal_notice', [
|
||||
'nextBillingDate' => $user_abo->next_date ?: __('abo.confirm_next_delivery_unknown'),
|
||||
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||
'withdrawal' => '<a href="'.asset('download/mivita_widerruf_formular.pdf').'" target="_blank">'.__('abo.confirm_withdrawal_link').'</a>',
|
||||
'agb' => '<a href="' . url('/agb') . '" target="_blank">' . __('abo.confirm_terms_link') . '</a>',
|
||||
'withdrawal' =>
|
||||
'<a href="' .
|
||||
asset('download/mivita_widerruf_formular.pdf') .
|
||||
'" target="_blank">' .
|
||||
__('abo.confirm_withdrawal_link') .
|
||||
'</a>',
|
||||
]) !!}
|
||||
</p>
|
||||
@if($hasOneTimeChanges)
|
||||
@if ($hasOneTimeChanges)
|
||||
<div class="d-flex flex-wrap justify-content-end">
|
||||
<button type="button" class="btn btn-default mr-2 mb-2 onetime-discard-changes">
|
||||
{{ __('abo.onetime_discard_changes') }}
|
||||
|
|
@ -101,5 +157,9 @@
|
|||
<span aria-hidden="true">✓</span> {{ __('abo.onetime_confirm_success') }}
|
||||
</div>
|
||||
@endif
|
||||
@if ($hasOneTimeChanges && $hasConfirmedOneTimeItems)
|
||||
<hr>
|
||||
<div class="alert alert-info small mb-2">{{ __('abo.onetime_pending_hint') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
49
resources/views/admin/abo/_retry_payment_modal.blade.php
Normal file
49
resources/views/admin/abo/_retry_payment_modal.blade.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
@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">×</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
|
||||
|
|
@ -7,6 +7,15 @@
|
|||
<span class="text-muted">{{ '#' . $user_abo->payone_userid }}</span>
|
||||
</h4>
|
||||
|
||||
@if (Session::has('alert-success'))
|
||||
<div class="col-sm-12">
|
||||
<div class="alert alert-success p-2 mt-2">
|
||||
<ul>
|
||||
<li>{{ Session::get('alert-success') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if (Session::has('alert-error'))
|
||||
<div class="col-sm-12">
|
||||
<div class="alert alert-danger p-2 mt-2">
|
||||
|
|
@ -90,6 +99,7 @@
|
|||
@include('admin.abo._executions')
|
||||
</div>
|
||||
|
||||
@include('admin.abo._retry_payment_modal', ['retryAction' => route('portal_abos_retry_payment', [$user_abo->id])])
|
||||
|
||||
<a href="{{ route('portal.my_subscriptions') }}" class="btn btn-sm btn-default float-right">{{ __('abo.back') }}</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@
|
|||
</h4>
|
||||
|
||||
|
||||
@if (Session::has('alert-success'))
|
||||
<div class="col-sm-12">
|
||||
<div class="alert alert-success p-2 mt-2">
|
||||
<ul>
|
||||
<li>{{ Session::get('alert-success') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if (Session::has('alert-error'))
|
||||
<div class="col-sm-12">
|
||||
<div class="alert alert-danger p-2 mt-2">
|
||||
|
|
@ -107,6 +116,7 @@
|
|||
@include('admin.abo._executions')
|
||||
</div>
|
||||
|
||||
@include('admin.abo._retry_payment_modal', ['retryAction' => route('user_abos_retry_payment', [$view, $user_abo->id])])
|
||||
|
||||
<a href="{{ route('user_abos', [$view]) }}" class="btn btn-sm btn-default float-right">{{ __('back') }}</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ 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::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/retry-payment/{view}/{id}', 'User\AboController@retryPayment')->name('user_abos_retry_payment');
|
||||
Route::get('/user/abo/datatable/{id}', 'User\AboController@datatable')->name('user_abo_datatable');
|
||||
Route::get('/user/abo/onetime-datatable/{id}', 'User\AboController@oneTimeDatatable')->name('user_abo_onetime_datatable');
|
||||
// Route to show team subscriptions (Abos)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ 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::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/retry-payment/{id}', [AboController::class, 'retryPayment'])->name('portal_abos_retry_payment');
|
||||
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::post('portal/modal/load', [AboController::class, 'modalLoad'])->name('modal_load');
|
||||
|
|
|
|||
269
tests/Feature/AboMakeOrderOneTimeTest.php
Normal file
269
tests/Feature/AboMakeOrderOneTimeTest.php
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<?php
|
||||
|
||||
use App\Cron\UserMakeOrder;
|
||||
use App\Models\Country;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOneTimeItem;
|
||||
use App\Models\UserAccount;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\AboOneTimeService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Baut ein vollständiges Berater-Abo ('me') inklusive Stammkunde, Referenz-Bestellung,
|
||||
* Versandland, einem regulären Abo-Artikel und einem verbindlich bestätigten Einmal-Artikel.
|
||||
*
|
||||
* @return array{abo: UserAbo, aboProductId: int, oneTimeProductId: int}
|
||||
*/
|
||||
function makeMakeOrderFixture(): array
|
||||
{
|
||||
$country = Country::create([
|
||||
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
|
||||
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]);
|
||||
|
||||
$shipping = Shipping::create(['name' => 'Standard', 'active' => true]);
|
||||
ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
|
||||
|
||||
$account = UserAccount::create([
|
||||
'salutation' => 'Herr',
|
||||
'first_name' => 'Max',
|
||||
'last_name' => 'Muster',
|
||||
'address' => 'Musterstr. 1',
|
||||
'zipcode' => '12345',
|
||||
'city' => 'Musterstadt',
|
||||
'country_id' => $country->id,
|
||||
'phone' => '123456',
|
||||
'same_as_billing' => 1,
|
||||
'language' => 'de',
|
||||
]);
|
||||
|
||||
$user = User::forceCreate([
|
||||
'email' => 'consultant-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$userShop = UserShop::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'TS'.substr(uniqid('', true), 0, 8),
|
||||
'slug' => 'ts-'.uniqid(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
'billing_email' => $user->email,
|
||||
'shipping_email' => $user->email,
|
||||
'billing_firstname' => 'Max',
|
||||
'billing_lastname' => 'Muster',
|
||||
'shipping_firstname' => 'Max',
|
||||
'shipping_lastname' => 'Muster',
|
||||
'same_as_billing' => 1,
|
||||
'is_for' => 'me',
|
||||
'is_from' => 'user_order',
|
||||
]);
|
||||
|
||||
ShoppingOrder::create([
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'auth_user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'country_id' => $country->id,
|
||||
'user_shop_id' => $userShop->id,
|
||||
'payment_for' => 3,
|
||||
'total' => 100,
|
||||
'subtotal' => 90,
|
||||
'total_shipping' => 100,
|
||||
'paid' => true,
|
||||
'is_abo' => true,
|
||||
'txaction' => 'paid',
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
$abo = UserAbo::create([
|
||||
'user_id' => $user->id,
|
||||
'member_id' => $user->id,
|
||||
'shopping_user_id' => $shoppingUser->id,
|
||||
'is_for' => 'me',
|
||||
'email' => $user->email,
|
||||
'payone_userid' => 900200,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 2,
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
$aboProduct = makeProduct(['12'], 119, 19);
|
||||
$oneTimeProduct = makeProduct(['2'], 50, 19);
|
||||
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $aboProduct->id,
|
||||
'qty' => 1,
|
||||
'comp' => 0,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $oneTimeProduct->id,
|
||||
'qty' => 2,
|
||||
'confirmed_qty' => 2,
|
||||
'confirmed_at' => now(),
|
||||
'price' => 50.0,
|
||||
'price_net' => 42.017,
|
||||
'tax_rate' => 19.0,
|
||||
'tax' => 7.983,
|
||||
'points' => 7,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return [
|
||||
'abo' => $abo->fresh(),
|
||||
'aboProductId' => $aboProduct->id,
|
||||
'oneTimeProductId' => $oneTimeProduct->id,
|
||||
];
|
||||
}
|
||||
|
||||
it('schreibt verbindlich bestätigte Einmal-Artikel mit is_abo_addon in die Bestellung', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
|
||||
$userMakeOrder = new UserMakeOrder($fixture['abo']);
|
||||
$userMakeOrder->createShoppingUser();
|
||||
$order = $userMakeOrder->makeShoppingOrder();
|
||||
|
||||
expect($order)->not->toBeFalse();
|
||||
|
||||
$aboItem = $order->shopping_order_items()
|
||||
->where('product_id', $fixture['aboProductId'])
|
||||
->first();
|
||||
$addonItem = $order->shopping_order_items()
|
||||
->where('product_id', $fixture['oneTimeProductId'])
|
||||
->first();
|
||||
|
||||
expect($aboItem)->not->toBeNull()
|
||||
->and((bool) $aboItem->is_abo_addon)->toBeFalse()
|
||||
->and($addonItem)->not->toBeNull()
|
||||
->and((bool) $addonItem->is_abo_addon)->toBeTrue()
|
||||
->and((int) $addonItem->qty)->toBe(2);
|
||||
});
|
||||
|
||||
it('lässt user_abos.amount der reine Abo-Betrag (ohne Einmal-Artikel)', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
|
||||
$userMakeOrder = new UserMakeOrder($fixture['abo']);
|
||||
$userMakeOrder->createShoppingUser();
|
||||
$order = $userMakeOrder->makeShoppingOrder();
|
||||
|
||||
$pureAboAmount = (float) $fixture['abo']->fresh()->amount; // Cent, nur Abo
|
||||
$combinedTotal = (float) $order->total_shipping * 100; // Cent, inkl. Einmal-Artikel
|
||||
|
||||
// Der kombinierte Abbuchungsbetrag enthält die Einmal-Artikel, der gespeicherte
|
||||
// Abo-Betrag jedoch nicht -> kombiniert ist größer.
|
||||
expect($combinedTotal)->toBeGreaterThan($pureAboAmount);
|
||||
});
|
||||
|
||||
it('nimmt nur bestätigte Einmal-Artikel auf, offene Entwürfe nicht', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
$abo = $fixture['abo'];
|
||||
|
||||
// Zusätzlicher, NICHT bestätigter Einmal-Artikel
|
||||
$pendingProduct = makeProduct(['2'], 30, 19);
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $pendingProduct->id,
|
||||
'qty' => 1,
|
||||
'price' => 30.0,
|
||||
'tax_rate' => 19.0,
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$userMakeOrder = new UserMakeOrder($abo);
|
||||
$userMakeOrder->createShoppingUser();
|
||||
$order = $userMakeOrder->makeShoppingOrder();
|
||||
|
||||
expect($order->shopping_order_items()->where('product_id', $pendingProduct->id)->exists())->toBeFalse()
|
||||
->and($order->shopping_order_items()->where('product_id', $fixture['oneTimeProductId'])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('entfernt beim Purge ALLE Einmal-Artikel (bestätigt, offen und soft-deleted)', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
$abo = $fixture['abo'];
|
||||
|
||||
// zusätzlich ein offener und ein soft-deleted Artikel
|
||||
$pending = makeProduct(['2'], 30, 19);
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $pending->id,
|
||||
'qty' => 1,
|
||||
'price' => 30.0,
|
||||
'tax_rate' => 19.0,
|
||||
'status' => 0,
|
||||
]);
|
||||
$trashed = makeProduct(['2'], 20, 19);
|
||||
$trashedItem = UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $trashed->id,
|
||||
'qty' => 1,
|
||||
'confirmed_qty' => 1,
|
||||
'confirmed_at' => now(),
|
||||
'price' => 20.0,
|
||||
'tax_rate' => 19.0,
|
||||
'status' => 1,
|
||||
]);
|
||||
$trashedItem->delete();
|
||||
|
||||
expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(3);
|
||||
|
||||
AboOneTimeService::purgeAfterExecution($abo);
|
||||
|
||||
expect(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('reduziert nach dem Purge nicht mehr benötigte Kompensationsprodukte', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
$abo = $fixture['abo'];
|
||||
|
||||
// Ein Comp-Abo-Artikel, der nach Wegfall der Einmal-Artikel nicht mehr nötig ist.
|
||||
$compProduct = makeProduct(['12'], 0, 0);
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $compProduct->id,
|
||||
'comp' => 1,
|
||||
'qty' => 1,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(1);
|
||||
|
||||
AboOneTimeService::purgeAfterExecution($abo);
|
||||
|
||||
expect(UserAboItem::where('user_abo_id', $abo->id)->where('comp', '>', 0)->count())->toBe(0)
|
||||
->and(UserAboOneTimeItem::withTrashed()->where('user_abo_id', $abo->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('entfernt durch reine Bestellerstellung KEINE Einmal-Artikel (behalten bei Fehler)', function () {
|
||||
$fixture = makeMakeOrderFixture();
|
||||
$abo = $fixture['abo'];
|
||||
|
||||
$userMakeOrder = new UserMakeOrder($abo);
|
||||
$userMakeOrder->createShoppingUser();
|
||||
$userMakeOrder->makeShoppingOrder();
|
||||
|
||||
// makeShoppingOrder läuft sowohl im Erfolgs- als auch im Fehlerfall; der Purge
|
||||
// erfolgt ausschließlich separat im Erfolgszweig. Daher bleiben die Artikel hier erhalten.
|
||||
expect(UserAboOneTimeItem::where('user_abo_id', $abo->id)->count())->toBe(1);
|
||||
});
|
||||
|
|
@ -129,6 +129,71 @@ describe('AboOneTimeService', function () {
|
|||
|
||||
expect($this->service->handleAction($abo, ['action' => 'foo']))->toBe(__('abo.onetime_action_invalid'));
|
||||
});
|
||||
|
||||
it('trennt bestätigte und offene Einmal-Artikel inkl. offener Zwischensumme', function () {
|
||||
$abo = makeMeAbo();
|
||||
$confirmed = makeProduct(['2'], 119, 19);
|
||||
$pending = makeProduct(['2'], 50, 19);
|
||||
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $confirmed->id,
|
||||
'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(),
|
||||
'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1,
|
||||
]);
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $pending->id,
|
||||
'qty' => 2, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0,
|
||||
]);
|
||||
|
||||
expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(1)
|
||||
->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1)
|
||||
->and(AboOneTimeService::pendingGross($abo))->toBe(100.0);
|
||||
});
|
||||
|
||||
it('behandelt einen geänderten bestätigten Artikel als offen', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['2'], 119, 19);
|
||||
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $product->id,
|
||||
'qty' => 3, 'confirmed_qty' => 1, 'confirmed_at' => now(),
|
||||
'price' => 119.0, 'tax_rate' => 19.0, 'status' => 0,
|
||||
]);
|
||||
|
||||
expect(AboOneTimeService::confirmedItems($abo)->count())->toBe(0)
|
||||
->and(AboOneTimeService::pendingItems($abo)->count())->toBe(1)
|
||||
->and(AboOneTimeService::pendingGross($abo))->toBe(357.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboOrderCart::addOneTimeItemsToYard', function () {
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
it('lädt nur verbindlich bestätigte Einmal-Artikel in den Warenkorb', function () {
|
||||
$abo = makeMeAbo();
|
||||
$confirmed = makeProduct(['2'], 119, 19);
|
||||
$pending = makeProduct(['2'], 50, 19);
|
||||
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $confirmed->id,
|
||||
'qty' => 1, 'confirmed_qty' => 1, 'confirmed_at' => now(),
|
||||
'price' => 119.0, 'tax_rate' => 19.0, 'status' => 1,
|
||||
]);
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id, 'product_id' => $pending->id,
|
||||
'qty' => 1, 'price' => 50.0, 'tax_rate' => 19.0, 'status' => 0,
|
||||
]);
|
||||
|
||||
$yard = Yard::instance(AboOrderCart::INSTANCE);
|
||||
$yard->destroy();
|
||||
|
||||
AboOrderCart::addOneTimeItemsToYard($abo->fresh());
|
||||
|
||||
expect($yard->content()->count())->toBe(1)
|
||||
->and((int) $yard->content()->first()->id)->toBe($confirmed->id);
|
||||
|
||||
$yard->destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AboOrderCart::buildOneTimeSnapshot', function () {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ uses(Tests\TestCase::class, RefreshDatabase::class);
|
|||
describe('Abo Einmal-Produkte Views', function () {
|
||||
beforeEach(fn () => makeShopEnv());
|
||||
|
||||
it('rendert die Einmal-Produktliste mit Position und Zwischensumme', function () {
|
||||
it('rendert die Einmal-Produktliste mit Position und offener Zwischensumme', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['2'], 119, 19);
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ describe('Abo Einmal-Produkte Views', function () {
|
|||
'price_net' => 100.0,
|
||||
'tax_rate' => 19.0,
|
||||
'tax' => 19.0,
|
||||
'status' => 1,
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$summary = ['one_time' => ['gross' => 238.0, 'net' => 200.0, 'tax' => 38.0]];
|
||||
|
|
@ -32,10 +32,50 @@ describe('Abo Einmal-Produkte Views', function () {
|
|||
])->render();
|
||||
|
||||
expect($html)->toContain($product->getLang('name'))
|
||||
->and($html)->toContain(__('abo.onetime_subtotal'))
|
||||
->and($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||||
->and($html)->toContain(__('abo.onetime_status_pending'))
|
||||
->and($html)->toContain('238,00');
|
||||
});
|
||||
|
||||
it('zeigt bestätigte und offene Artikel mit getrennten Zwischensummen', function () {
|
||||
$abo = makeMeAbo();
|
||||
$confirmed = makeProduct(['2'], 119, 19);
|
||||
$pending = makeProduct(['2'], 50, 19);
|
||||
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $confirmed->id,
|
||||
'qty' => 1,
|
||||
'confirmed_qty' => 1,
|
||||
'confirmed_at' => now(),
|
||||
'price' => 119.0,
|
||||
'tax_rate' => 19.0,
|
||||
'status' => 1,
|
||||
]);
|
||||
UserAboOneTimeItem::create([
|
||||
'user_abo_id' => $abo->id,
|
||||
'product_id' => $pending->id,
|
||||
'qty' => 1,
|
||||
'price' => 50.0,
|
||||
'tax_rate' => 19.0,
|
||||
'status' => 0,
|
||||
]);
|
||||
|
||||
$html = view('admin.abo._order_onetime_show', [
|
||||
'user_abo' => $abo->fresh(),
|
||||
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain(__('abo.onetime_confirmed_subtotal'))
|
||||
->and($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||||
->and($html)->toContain(__('abo.onetime_new_total'))
|
||||
->and($html)->toContain(__('abo.onetime_status_confirmed'))
|
||||
->and($html)->toContain(__('abo.onetime_status_pending'))
|
||||
->and($html)->toContain(__('abo.onetime_pending_hint'))
|
||||
->and($html)->toContain('50,00')
|
||||
->and($html)->toContain('169,00');
|
||||
});
|
||||
|
||||
it('zeigt bei unbestätigten Einmal-Artikeln die Bestätigungsbuttons', function () {
|
||||
$abo = makeMeAbo();
|
||||
$product = makeProduct(['2'], 119, 19);
|
||||
|
|
@ -56,7 +96,7 @@ describe('Abo Einmal-Produkte Views', function () {
|
|||
'summary' => ['one_time' => ['gross' => 119.0], 'total_with_shipping' => 219.0],
|
||||
])->render();
|
||||
|
||||
expect($html)->toContain(__('abo.onetime_next_delivery_total'))
|
||||
expect($html)->toContain(__('abo.onetime_pending_subtotal'))
|
||||
->and($html)->toContain(__('abo.onetime_legal_notice', [
|
||||
'nextBillingDate' => $abo->next_date,
|
||||
'agb' => '<a href="'.url('/agb').'" target="_blank">'.__('abo.confirm_terms_link').'</a>',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Models\ShoppingOrderItem;
|
|||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOneTimeItem;
|
||||
use App\Services\AboHelper;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
|
|
@ -82,6 +83,48 @@ describe('isOneTimeWindowOpen', function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isOneTimeFeatureVisible', function () {
|
||||
it('ist sichtbar, wenn das Fenster offen ist und ein VIP-User (Admin) eingeloggt ist', function () {
|
||||
$vip = new User;
|
||||
$vip->admin = 1;
|
||||
$this->actingAs($vip, 'user');
|
||||
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2); // innerhalb des 4-Tage-Fensters
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeTrue();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar für eingeloggte Nicht-VIP-User', function () {
|
||||
$user = new User;
|
||||
$user->admin = 0;
|
||||
$this->actingAs($user, 'user');
|
||||
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2);
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar ohne User auf dem user-Guard (Portal/Endkunde)', function () {
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(2);
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
|
||||
});
|
||||
|
||||
it('ist nicht sichtbar bei geschlossenem Fenster, auch für VIP-User', function () {
|
||||
$vip = new User;
|
||||
$vip->admin = 1;
|
||||
$this->actingAs($vip, 'user');
|
||||
|
||||
$abo = new UserAbo;
|
||||
$abo->next_date = now()->addDays(30); // außerhalb des Fensters
|
||||
|
||||
expect(AboHelper::isOneTimeFeatureVisible($abo))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserAboOneTimeItem Model', function () {
|
||||
it('definiert die Beziehung one_time_items auf UserAbo', function () {
|
||||
$relation = (new UserAbo)->one_time_items();
|
||||
|
|
|
|||
78
tests/Feature/AboUserRetryTest.php
Normal file
78
tests/Feature/AboUserRetryTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\User\AboController;
|
||||
use App\Models\UserAbo;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function makeRetryOwnerAbo(): array
|
||||
{
|
||||
$owner = User::forceCreate([
|
||||
'email' => 'owner-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$abo = UserAbo::create([
|
||||
'user_id' => $owner->id,
|
||||
'member_id' => $owner->id,
|
||||
'shopping_user_id' => 1,
|
||||
'is_for' => 'me',
|
||||
'email' => $owner->email,
|
||||
'payone_userid' => 900300,
|
||||
'clearingtype' => 'cc',
|
||||
'active' => true,
|
||||
'status' => 3,
|
||||
'abo_interval' => 5,
|
||||
'next_date' => now()->toDateString(),
|
||||
]);
|
||||
|
||||
return ['owner' => $owner, 'abo' => $abo];
|
||||
}
|
||||
|
||||
it('führt den User-Retry für den eigenen Abo aus und leitet zur Detailseite zurück', 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();
|
||||
$this->actingAs($fixture['owner']);
|
||||
|
||||
mock(AboRetryPaymentService::class)
|
||||
->shouldReceive('retry')
|
||||
->once()
|
||||
->andReturn(['success' => true, 'message' => 'OK']);
|
||||
|
||||
$controller = new AboController(app(AboRepository::class));
|
||||
$response = $controller->retryPayment('me', $fixture['abo']->id, app(AboRetryPaymentService::class));
|
||||
|
||||
expect($response->getTargetUrl())->toContain('/user/abos/detail/me/'.$fixture['abo']->id);
|
||||
expect(session('alert-success'))->toBe('OK');
|
||||
});
|
||||
|
||||
it('verhindert den User-Retry für ein fremdes Abo (403)', function () {
|
||||
$fixture = makeRetryOwnerAbo();
|
||||
|
||||
$stranger = User::forceCreate([
|
||||
'email' => 'stranger-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$service = mock(AboRetryPaymentService::class);
|
||||
$service->shouldNotReceive('retry');
|
||||
|
||||
$controller = new AboController(app(AboRepository::class));
|
||||
|
||||
expect(fn () => $controller->retryPayment('me', $fixture['abo']->id, $service))
|
||||
->toThrow(HttpException::class);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue