Abo: Einmalprodukte bereits bei der Abo-Anlage + es/fr-Sprachsync
Einmalprodukte (Normalsortiment, show_on 3) können jetzt schon beim Abo-Abschluss hinzugefügt werden – im Portal-Wizard (Abschnitt im Upgrade-Schritt) und in allen drei Sales-Center-Abo-Strecken (eigene Produktliste). Variante A: Zeilen laufen als Cart-Option abo_addon mit durch den Checkout, werden sofort bezahlt und mit der Erstlieferung verschickt (Rechnung/Lieferschein gruppieren automatisch). - Checkout persistiert is_abo_addon (CheckoutRepository, PaymentHelper) - createAboItems/ensureUserAboItemsFromLatestOrder überspringen Addons (sonst würden Einmalprodukte dauerhaft wiederkehren); amount ohne Addon-Anteil; aboHasBaseProduct ignoriert Addon-Zeilen - CreateAboReplacementOrder propagiert is_abo_addon in Ersatzbestellungen - iq-shopping-cart.js: zweite Datatable + abo_addon-Flag-Weitergabe - i18n: neue abo.onetime_create_*-Keys (de/en/es/fr); zusätzlich es/fr komplett synchronisiert (abo-Kartenblock, navigation, payment, weborder inkl. Entfernung PAYONE-Altlasten, withdrawal.php neu) - Tests: AboCreateOneTimeItemsTest, AboCreateOneTimeCartTest, AboPortalCreateOneTimeTest (17 neue, 122 Tests gesamt grün) Plan: dev/2026-07-21-abo-einmalprodukte-anlage/ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
1e7ae92d0c
commit
cdb121fde0
32 changed files with 1479 additions and 52 deletions
|
|
@ -188,6 +188,7 @@ class CreateAboReplacementOrder extends Command
|
|||
'discount' => $item->discount,
|
||||
'points' => $item->points,
|
||||
'slug' => $item->slug,
|
||||
'is_abo_addon' => (bool) $item->is_abo_addon,
|
||||
];
|
||||
|
||||
$subtotal += $lineNet;
|
||||
|
|
@ -340,6 +341,7 @@ class CreateAboReplacementOrder extends Command
|
|||
'discount' => $item['discount'],
|
||||
'points' => $item['points'],
|
||||
'slug' => $item['slug'],
|
||||
'is_abo_addon' => $item['is_abo_addon'] ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use App\Services\AboOneTimeService;
|
|||
use App\Services\AboOrderCart;
|
||||
use App\Services\AboPaymentMethodService;
|
||||
use App\Services\AboRecoveryCheckoutService;
|
||||
use App\Services\ProductOrderContext;
|
||||
use App\Services\Shop;
|
||||
use App\Services\UserService;
|
||||
use App\Services\Util;
|
||||
|
|
@ -684,6 +685,10 @@ class AboController extends Controller
|
|||
->whereJsonContains('show_on', ['13'])
|
||||
->orderBy('pos', 'ASC')
|
||||
->get(),
|
||||
'onetime_products' => Product::where('active', true)
|
||||
->whereJsonContains('show_on', ProductOrderContext::oneTimeShowOnIds())
|
||||
->orderBy('pos', 'ASC')
|
||||
->get(),
|
||||
'step' => 0,
|
||||
];
|
||||
if (Request::get('action') == 'back') {
|
||||
|
|
@ -721,6 +726,7 @@ class AboController extends Controller
|
|||
UserService::setInstance($this->instance);
|
||||
UserService::initCustomerYard($shopping_user, 'abo-ot-customer');
|
||||
$this->upgradeProductToCart();
|
||||
$this->oneTimeProductsToCart();
|
||||
$data['step'] = 4;
|
||||
break;
|
||||
case 5:
|
||||
|
|
@ -814,11 +820,83 @@ class AboController extends Controller
|
|||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die im Upgrade-Schritt gewählten Einmalprodukte (Normalsortiment) als
|
||||
* getrennte Cart-Zeilen mit Option `abo_addon = true`: nur erste Lieferung, wird beim
|
||||
* Checkout als is_abo_addon persistiert und nicht Teil des wiederkehrenden Abos.
|
||||
*/
|
||||
private function oneTimeProductsToCart(): void
|
||||
{
|
||||
$data = Request::all();
|
||||
|
||||
if (! isset($data['onetime_product_qty']) || ! is_array($data['onetime_product_qty'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data['onetime_product_qty'] as $product_id => $quantity) {
|
||||
$product = Product::find($product_id);
|
||||
|
||||
if (! $product || ! ProductOrderContext::productMatchesShowOn($product, ProductOrderContext::oneTimeShowOnIds())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->addOneTimeProductToCart($product, (int) $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
private function addOneTimeProductToCart(Product $product, int $quantity): void
|
||||
{
|
||||
$cartItems = $this->yard->search(function ($item) use ($product) {
|
||||
return $item->id === $product->id && ($item->options->abo_addon ?? false);
|
||||
});
|
||||
|
||||
if ($quantity <= 0) {
|
||||
foreach ($cartItems as $item) {
|
||||
$this->yard->remove($item->rowId);
|
||||
}
|
||||
$this->yard->reCalculateShippingPrice();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cartItems->count() > 0) {
|
||||
$this->yard->update($cartItems->first()->rowId, $quantity);
|
||||
} else {
|
||||
$image = $product->images->first()->slug ?? '';
|
||||
$price = $product->getPriceWith(
|
||||
$this->yard->getUserTaxFree(),
|
||||
false,
|
||||
$this->yard->getUserCountry()
|
||||
);
|
||||
|
||||
$this->yard->add(
|
||||
$product->id,
|
||||
$product->getLang('name'),
|
||||
$quantity,
|
||||
$price,
|
||||
false,
|
||||
false,
|
||||
[
|
||||
'image' => $image,
|
||||
'slug' => $product->slug,
|
||||
'weight' => $product->weight,
|
||||
'points' => $product->points,
|
||||
'no_commission' => $product->no_commission,
|
||||
'no_free_shipping' => $product->no_free_shipping,
|
||||
'show_on' => $product->show_on,
|
||||
'abo_addon' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->yard->reCalculateShippingPrice();
|
||||
}
|
||||
|
||||
private function addProductToCart($product, $quantity)
|
||||
{
|
||||
// Suche nach dem Produkt im Warenkorb
|
||||
// Suche nach dem Produkt im Warenkorb (Einmal-Zeilen haben eine eigene rowId und bleiben außen vor)
|
||||
$cartItems = $this->yard->search(function ($item) use ($product) {
|
||||
return $item->id === $product->id;
|
||||
return $item->id === $product->id && ! ($item->options->abo_addon ?? false);
|
||||
});
|
||||
|
||||
// Wenn die Menge 0 ist, entferne das Produkt
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use App\Services\UserService;
|
|||
use App\Services\Util;
|
||||
use App\User;
|
||||
use Auth;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Request;
|
||||
|
|
@ -651,9 +652,10 @@ class OrderController extends Controller
|
|||
{
|
||||
$isAbo = Request::get('is_abo');
|
||||
$shippingIsFor = Request::get('shipping_is_for');
|
||||
$isAddon = (bool) $isAbo && filter_var(Request::get('abo_addon'), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if ($shippingIsFor === 'me' || $shippingIsFor === 'abo-me') {
|
||||
$show_on_ids = $isAbo ? ['12', '13'] : ['2'];
|
||||
$show_on_ids = $isAddon ? ProductOrderContext::oneTimeShowOnIds() : ($isAbo ? ['12', '13'] : ['2']);
|
||||
|
||||
$query = Product::with('product_buyings')
|
||||
->select('products.*')
|
||||
|
|
@ -671,7 +673,7 @@ class OrderController extends Controller
|
|||
[$show_on_ids[0], isset($show_on_ids[1]) ? $show_on_ids[1] : $show_on_ids[0]]
|
||||
);
|
||||
} else {
|
||||
$show_on_ids = $isAbo ? ['12', '13'] : ['3'];
|
||||
$show_on_ids = $isAddon ? ProductOrderContext::oneTimeShowOnIds() : ($isAbo ? ['12', '13'] : ['3']);
|
||||
|
||||
$query = Product::select('products.*')
|
||||
->where('active', true)
|
||||
|
|
@ -690,8 +692,18 @@ class OrderController extends Controller
|
|||
}
|
||||
|
||||
return \DataTables::eloquent($query)
|
||||
->addColumn('product', function (Product $product) {
|
||||
$cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id);
|
||||
->addColumn('product', function (Product $product) use ($isAddon) {
|
||||
if ($isAddon) {
|
||||
$cartItem = Yard::instance('shopping')->search(function ($item) use ($product) {
|
||||
return (int) $item->id === (int) $product->id && ($item->options->abo_addon ?? false);
|
||||
})->first();
|
||||
$inputName = 'onetime_qty_'.$product->id;
|
||||
$addonAttr = ' data-abo-addon="1"';
|
||||
} else {
|
||||
$cartItem = Yard::instance('shopping')->getCartItemByProduct($product->id);
|
||||
$inputName = 'product_qty_'.$product->id;
|
||||
$addonAttr = '';
|
||||
}
|
||||
$qty = isset($cartItem->qty) ? $cartItem->qty : 0;
|
||||
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
|
||||
|
||||
|
|
@ -699,11 +711,11 @@ class OrderController extends Controller
|
|||
<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 remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">-</button>
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra remove-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.'>-</button>
|
||||
</span>
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="product_qty_'.$product->id.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'" value="'.$qty.'">
|
||||
<input type="text" class="form-control text-center input-extra table-input-event-onchange" name="'.$inputName.'" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.' value="'.$qty.'">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'">+</button>
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-basket" data-row-id="'.$rowId.'" data-product-id="'.$product->id.'"'.$addonAttr.'>+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>';
|
||||
|
|
@ -823,8 +835,12 @@ class OrderController extends Controller
|
|||
}
|
||||
|
||||
$isAbo = str_contains($is_for, 'abo');
|
||||
$isAddon = $isAbo && filter_var($data['abo_addon'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
$qty = isset($data['qty']) ? (int) $data['qty'] : 0;
|
||||
if ($qty > 0 && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
||||
if ($qty > 0 && $isAddon && ! ProductOrderContext::productMatchesShowOn($product, ProductOrderContext::oneTimeShowOnIds())) {
|
||||
return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type'));
|
||||
}
|
||||
if ($qty > 0 && ! $isAddon && ! ProductOrderContext::isProductAllowedInContext($product, $isAbo, $is_for)) {
|
||||
return $this->cartUpdateRejected($data, __('msg.cart_product_not_allowed_for_order_type'));
|
||||
}
|
||||
|
||||
|
|
@ -837,6 +853,13 @@ class OrderController extends Controller
|
|||
$image = $product->images->first()->slug;
|
||||
}
|
||||
|
||||
$options = ['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on];
|
||||
if ($isAddon) {
|
||||
// Eigene Cart-Zeile (rowId hängt an den Optionen): wird beim Checkout als
|
||||
// is_abo_addon persistiert und nicht in die dauerhaften Abo-Positionen übernommen.
|
||||
$options['abo_addon'] = true;
|
||||
}
|
||||
|
||||
// Get the cart item
|
||||
if ($is_for === 'ot-customer' || $is_for === 'abo-ot-customer') {
|
||||
$cartItem = Yard::instance('shopping')
|
||||
|
|
@ -847,7 +870,7 @@ class OrderController extends Controller
|
|||
round($product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), false, Yard::instance('shopping')->getUserCountry()), 1),
|
||||
false,
|
||||
false,
|
||||
['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]
|
||||
$options
|
||||
);
|
||||
} else {
|
||||
$cartItem = Yard::instance('shopping')
|
||||
|
|
@ -858,7 +881,7 @@ class OrderController extends Controller
|
|||
$product->getPriceWith(Yard::instance('shopping')->getUserTaxFree(), true, Yard::instance('shopping')->getUserCountry()),
|
||||
false,
|
||||
false,
|
||||
['image' => $image, 'slug' => $product->slug, 'weight' => $product->weight, 'points' => $product->points, 'no_commission' => $product->no_commission, 'no_free_shipping' => $product->no_free_shipping, 'show_on' => $product->show_on]
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -891,7 +914,7 @@ class OrderController extends Controller
|
|||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function cartUpdateRejected(array $data, string $message): \Illuminate\Http\JsonResponse
|
||||
private function cartUpdateRejected(array $data, string $message): JsonResponse
|
||||
{
|
||||
$data['error_message'] = $message;
|
||||
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class CheckoutRepository extends BaseRepository
|
|||
'discount' => $item->options->no_commission ? 0 : $shopping_order->getUserDiscount(),
|
||||
'points' => $item->options->points,
|
||||
'slug' => $item->options->slug,
|
||||
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
|
||||
];
|
||||
if ($shopping_user->is_from === 'homeparty') {
|
||||
$data['homeparty_id'] = (int) $shopping_user->homeparty_id;
|
||||
|
|
@ -189,6 +190,7 @@ class CheckoutRepository extends BaseRepository
|
|||
'discount' => $item->options->no_commission ? 0 : $shopping_order->getUserDiscount(),
|
||||
'points' => $item->options->points,
|
||||
'slug' => $item->options->slug,
|
||||
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
|
||||
];
|
||||
|
||||
if ($shopping_user->is_from === 'homeparty') {
|
||||
|
|
|
|||
|
|
@ -153,6 +153,9 @@ class AboHelper
|
|||
public static function aboHasBaseProduct($yard_products)
|
||||
{
|
||||
foreach ($yard_products as $product) {
|
||||
if ($product->options->abo_addon ?? false) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($product->options->show_on)) {
|
||||
if (in_array('12', $product->options->show_on)) {
|
||||
return true;
|
||||
|
|
@ -343,6 +346,19 @@ class AboHelper
|
|||
$payment_transaction = $shopping_payment->payment_transactions->last();
|
||||
$payoneUserId = ! $isStripe && $payment_transaction ? (int) $payment_transaction->userid : 0;
|
||||
|
||||
/*
|
||||
* Einmalprodukte der Erstbestellung (is_abo_addon) gehören nicht zum wiederkehrenden
|
||||
* Abo-Betrag: amount analog zu AboOrderCart::makeOrderYard() (Abo-Artikel + Versand,
|
||||
* in Cent) um den Addon-Bruttoanteil reduzieren.
|
||||
*/
|
||||
$aboAmount = (int) $shopping_payment->amount;
|
||||
$addonGross = (float) $order->shopping_order_items
|
||||
->where('is_abo_addon', true)
|
||||
->reduce(fn ($carry, $item) => $carry + ((float) $item->price * (int) $item->qty), 0.0);
|
||||
if ($addonGross > 0) {
|
||||
$aboAmount = max(0, $aboAmount - (int) round($addonGross * 100));
|
||||
}
|
||||
|
||||
// next_date immer im nächsten Monat starten
|
||||
// is auth_user_id = Berater bestellung
|
||||
// is member_id = Kunden bestellung
|
||||
|
|
@ -360,7 +376,7 @@ class AboHelper
|
|||
'clearingtype' => $shopping_payment->clearingtype,
|
||||
'wallettype' => $shopping_payment->wallettype,
|
||||
'carddata' => $shopping_payment->carddata,
|
||||
'amount' => $shopping_payment->amount,
|
||||
'amount' => $aboAmount,
|
||||
'status' => 1,
|
||||
'abo_interval' => $aboInterval,
|
||||
'start_date' => now(),
|
||||
|
|
@ -483,6 +499,9 @@ class AboHelper
|
|||
public static function createAboItems($user_abo, ShoppingPayment $shopping_payment)
|
||||
{
|
||||
foreach ($shopping_payment->shopping_order->shopping_order_items as $item) {
|
||||
if ($item->is_abo_addon) {
|
||||
continue;
|
||||
}
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $user_abo->id,
|
||||
'product_id' => $item->product_id,
|
||||
|
|
@ -525,6 +544,9 @@ class AboHelper
|
|||
}
|
||||
|
||||
foreach ($order->shopping_order_items as $item) {
|
||||
if ($item->is_abo_addon) {
|
||||
continue;
|
||||
}
|
||||
UserAboItem::create([
|
||||
'user_abo_id' => $userAbo->id,
|
||||
'product_id' => $item->product_id,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ class PaymentHelper
|
|||
'discount' => $item->options->no_commission ? 0 : $shopping_order->getUserDiscount(),
|
||||
'points' => $item->options->points,
|
||||
'slug' => $item->options->slug,
|
||||
'is_abo_addon' => (bool) ($item->options->abo_addon ?? false),
|
||||
];
|
||||
$shopping_order_item = ShoppingOrderItem::create($data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ class ProductOrderContext
|
|||
return $isAbo ? ['12', '13'] : ['3'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sortiment für Einmalprodukte (Abo-Zusatzartikel, nur nächste/erste Lieferung):
|
||||
* normales Bestellsortiment – identisch zum Einmalprodukte-Feature laufender Abos.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function oneTimeShowOnIds(): array
|
||||
{
|
||||
return ['3'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowedIds
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
# Entwicklungsplan: Einmalprodukte bereits bei der Abo-Anlage hinzufügen
|
||||
|
||||
> **Status:** In Planung · **Erstellt:** 2026-07-21 · **Basis-Feature:** `dev/2026-06-05/ENTWICKLUNGSPLAN-ABO-EINMALPRODUKTE.md` (Einmalprodukte für laufende Abos, Phasen 1–7 umgesetzt)
|
||||
|
||||
## Dokumentations-Hinweis (verbindlich)
|
||||
|
||||
Dieser Plan wird **Schritt für Schritt** abgearbeitet. Für **jede Phase** gilt:
|
||||
|
||||
- Der **Umsetzungsstatus** in diesem Dokument wird nach Abschluss eines Schritts auf `ERLEDIGT` gesetzt (mit Datum, betroffenen Dateien, Zeilenangaben und kurzer Begründung).
|
||||
- **Alle Phasen, Hinweise und getroffenen Detail-Entscheidungen** werden sauber dokumentiert – auch Abweichungen vom Plan und Verworfenes (mit Begründung).
|
||||
- Jede Änderung wird **programmatisch getestet** (Pest), bevor die Phase als erledigt gilt.
|
||||
- Nach Code-Änderungen läuft `./vendor/bin/pint --dirty`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Ziel & Briefing
|
||||
|
||||
Bisher können Einmalprodukte (normales Bestellsortiment, `show_on = '3'`) nur zu **laufenden** Abos hinzugefügt werden – innerhalb des Zeitfensters vor der nächsten Ausführung. Sie gelten nur für die nächste Lieferung, erscheinen auf Rechnung + Lieferschein (eigene Gruppe „Einmalig hinzugefügte Artikel") und werden nach erfolgreicher Ausführung entfernt.
|
||||
|
||||
**Neu:** Auch beim **Abschluss** (Anlage) eines Abos sollen Einmalprodukte hinzugefügt werden können – also im Abo-Anlage-Prozess (Portal-Wizard und Sales-Center-Bestellstrecke), bevor das Abo existiert.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ist-Zustand (verifiziert am 2026-07-21, alle 42 Einmalprodukte-Tests grün)
|
||||
|
||||
### 2.1 Bestehendes Einmalprodukte-Feature (laufende Abos)
|
||||
|
||||
| Baustein | Umsetzung | Schlüsseldateien |
|
||||
| -------- | --------- | ---------------- |
|
||||
| Datenmodell | Tabelle `user_abo_one_time_items` (Preis-Snapshot, `confirmed_qty`/`confirmed_at`, SoftDeletes); Flag `is_abo_addon` auf `shopping_order_items` | `app/Models/UserAboOneTimeItem.php`, `app/Models/UserAbo.php:358`, `app/Models/ShoppingOrderItem.php:89,110,119-131` |
|
||||
| Zeitfenster | Setting `abo-onetime-window-days`, Default **4** Kalendertage (`DEFAULT_ONETIME_WINDOW_DAYS`); VIP (`admin>=1`) umgeht das Fenster | `app/Services/AboHelper.php:202-249` |
|
||||
| Service | `AboOneTimeService::handleAction()` (add/update/remove/confirm/discard); nur **bestätigte** Items (`confirmed_at` gesetzt UND `confirmed_qty == qty`) fließen in die Ausführung | `app/Services/AboOneTimeService.php:19-160` |
|
||||
| Ausführung | `UserMakeOrder`: `makeOrderYard()` (reine Abo-Produkte, setzt `user_abos.amount`) → `AboOrderCart::addOneTimeItemsToYard()` (Cart-Option `abo_addon=true`, Snapshot-Preis) → Order-Item bekommt `is_abo_addon` | `app/Cron/UserMakeOrder.php:220,224,315`, `app/Services/AboOrderCart.php:242-287` |
|
||||
| Purge | Nach erfolgreicher Abbuchung `AboOneTimeService::purgeAfterExecution()` (Force-Delete + Comp-Rebalancing); auch nach Recovery-Zahlung | `app/Cron/UserMakeAboOrder.php` (via Command), `app/Services/AboHelper.php:480` |
|
||||
| PDF | Rechnung + Lieferschein gruppieren nach `is_abo_addon` (Überschrift `pdf.abo_addon_items_heading`) | `resources/views/pdf/invoice-detail.blade.php:30-49`, `resources/views/pdf/delivery-detail.blade.php:14-32` |
|
||||
| UI | Portal (`Portal/AboController@oneTime`) + Sales Center (`User/AboController@oneTime`), gemeinsame Partials `admin/abo/_order_onetime*.blade.php`, Produkt-Datatable gefiltert auf `show_on` enthält `'3'` | `routes/domains/portal.php:59-66`, `routes/domains/crm.php:164-174` |
|
||||
| Reminder | `abo:send-execution-reminder` (06:00) + `abo:preview-reminder` | `app/Console/Commands/AboSendExecutionReminder.php` |
|
||||
|
||||
### 2.2 Abo-Anlage heute (zwei Strecken, Einmalprodukte spielen KEINE Rolle)
|
||||
|
||||
**A) Portal (Kunde, Self-Service):** `Portal/AboController@myAboCreate` – Step-Wizard (0–5): Basis-Produkte `show_on='12'`, Upgrade-Produkte `show_on='13'`, Yard-Instanz `subscription`-Cart mit `is_for='abo-ot-customer'`, dann `processCheckout()` → `ShoppingInstance` (`is_abo=true`, `abo_interval`) → Standard-Checkout. Datei: `app/Http/Controllers/Portal/AboController.php:658-905`.
|
||||
|
||||
**B) Sales Center (Berater):** `User/OrderController` Bestellstrecke mit `$for ∈ {abo-me, abo-ot-member, abo-ot-customer}` – Produktliste/Datatable auf `show_on 12/13` beschränkt (`OrderController.php:656,674`), Cart-Add prüft hart `ProductOrderContext::isProductAllowedInContext()` (`OrderController.php:827` → Ablehnung `cart_product_not_allowed_for_order_type`).
|
||||
|
||||
**Abo-Entstehung nach Zahlung:** `AboHelper::createNewAbo(ShoppingPayment)` (`AboHelper.php:302-387`) – aufgerufen aus `CheckoutController` (659, 702), `Payment.php:295`, Stripe-Webhook. Legt `UserAbo` an (`amount = shopping_payment->amount`, `next_date = getFirstAboDate()`) und ruft `createAboItems()` (`AboHelper.php:483`): **kopiert ALLE `shopping_order_items` 1:1 in `user_abo_items`** (= wiederkehrende Positionen).
|
||||
|
||||
### 2.3 Relevante Lücken (für dieses Feature entscheidend)
|
||||
|
||||
1. **`is_abo_addon` wird beim Checkout nicht persistiert.** Nur der Cron-Pfad (`UserMakeOrder.php:315`) schreibt das Flag. Die Checkout-Order-Item-Erzeugung in `CheckoutRepository.php:~180-204` und `PaymentHelper::makeShoppingOrder()` (`PaymentHelper.php:115-136`) kennt die Cart-Option `abo_addon` nicht.
|
||||
2. **`createAboItems()` kopiert alles** – Einmalprodukte in der Erstbestellung würden zu **dauerhaften** Abo-Positionen. Gleiche Falle: `ensureUserAboItemsFromLatestOrder()` (`AboHelper.php:503-536`) und `RepairMissingAboFromOrders` (nutzt `createNewAbo` → derselbe Fix greift).
|
||||
3. **Produktkontext blockiert:** `ProductOrderContext::allowedShowOnIds(true, …)` = `['12','13']` – Sortiment `'3'` ist im Abo-Cart heute verboten (gewollt, damit keine Normalprodukte zu Abo-Positionen werden).
|
||||
4. **`user_abos.amount`** würde bei Anlage die Einmalprodukte enthalten (`= shopping_payment->amount`); korrigiert sich erst bei der ersten Cron-Ausführung durch `makeOrderYard()`. Bis dahin zeigt das Abo einen zu hohen Betrag.
|
||||
|
||||
---
|
||||
|
||||
## 3. Grundsatz-Entscheidung: Wann werden die Einmalprodukte geliefert?
|
||||
|
||||
| Variante | Beschreibung | Bewertung |
|
||||
| -------- | ------------ | --------- |
|
||||
| **A – Mit der Erstbestellung (EMPFOHLEN)** | Einmalprodukte kommen in den Anlage-Warenkorb (Cart-Option `abo_addon=true`), werden **sofort mit dem Checkout bezahlt** und mit der ersten Lieferung verschickt. Auf Erstrechnung + Lieferschein erscheinen sie als „Einmalig hinzugefügte Artikel" (Gruppierung existiert schon). Sie werden bei der Abo-Anlage **nicht** in `user_abo_items` übernommen → tauchen in keiner Folgelieferung auf. Kein Eintrag in `user_abo_one_time_items` nötig (nichts zu purgen). | Natürliche Semantik von „beim Abschluss hinzufügen": ein Bezahlvorgang, sofortige Lieferung. Technisch schlank. |
|
||||
| B – Für die erste reguläre Ausführung | Bei Anlage werden die Produkte als **bestätigte** `user_abo_one_time_items` ans neue Abo gehängt; Lieferung + Abbuchung erst mit der ersten Cron-Ausführung (`next_date`, ~1 Monat später). | Getrennte spätere Abbuchung (Überraschungseffekt), Kunde wartet einen Monat, mehr Moving Parts (Snapshot ohne initialisierten Abo-Yard). Nur sinnvoll, wenn fachlich explizit gewünscht. |
|
||||
|
||||
> **Annahme A1:** Variante **A**. Bitte vor Umsetzungsbeginn bestätigen. (Variante B wäre als Zusatzoption später nachrüstbar, z. B. Checkbox „erst mit der nächsten regulären Lieferung".)
|
||||
|
||||
### Weitere Annahmen
|
||||
|
||||
| # | Thema | Annahme |
|
||||
| - | ----- | ------- |
|
||||
| A2 | Betroffene Strecken | Beide: Portal-Wizard **und** Sales-Center-Abo-Strecken (`abo-me`, `abo-ot-member`, `abo-ot-customer`). |
|
||||
| A3 | Zeitfenster | Bei der Anlage **irrelevant** – das Fenster (`abo-onetime-window-days`) gilt nur für laufende Abos. Kein Gate bei Anlage. |
|
||||
| A4 | Bestätigungs-Schritt | Entfällt – der Checkout selbst ist die verbindliche Bestätigung (kein `confirm`-Workflow wie im laufenden Abo). |
|
||||
| A5 | Sortiment | Wie im Bestand: normales Bestellsortiment `show_on` enthält `'3'`; für `abo-me` analog Beraterbestellung `'2'` (klären, siehe Phase 3/4). |
|
||||
| A6 | Basis-Produkt-Pflicht | `aboHasBaseProduct()`-Prüfung bleibt unverändert (Einmalprodukte zählen nicht als Basis-Produkt – Prüfung ist Whitelist auf Basis-Produkte, Zusatzitems stören nicht). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Lösungsarchitektur (Variante A)
|
||||
|
||||
Kernidee: **Der Anlage-Warenkorb transportiert Einmalprodukte als normale Cart-Rows mit Option `abo_addon=true`** – exakt die Option, die der Cron-Pfad heute schon nutzt. Damit:
|
||||
|
||||
1. Checkout/Zahlung/Versand funktionieren unverändert (Position ist eine normale Warenkorbzeile: Gewicht, Versandkosten, Steuer, Punkte inklusive).
|
||||
2. Die Order-Item-Erzeugung persistiert das Flag → `shopping_order_items.is_abo_addon = true` → **PDF-Gruppierung auf Rechnung/Lieferschein greift automatisch** (bestehende Logik + Tests).
|
||||
3. `createAboItems()` überspringt `is_abo_addon`-Positionen → nichts wird dauerhaft.
|
||||
|
||||
Kein neues Datenmodell, keine Migration, kein Purge nötig.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phasen
|
||||
|
||||
### Phase 1 — Backend-Fundament: Flag durch den Checkout tragen
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Umsetzung:**
|
||||
- `app/Repositories/CheckoutRepository.php`: beide Order-Item-Zweige (Update ~Z.155, Create ~Z.193) schreiben `'is_abo_addon' => (bool)($item->options->abo_addon ?? false)`.
|
||||
- `app/Services/PaymentHelper.php::makeShoppingOrder()`: identisch ergänzt.
|
||||
- `Api/ShoppingUserController.php:700` bewusst NICHT angefasst (API-Bestellungen sind keine Abo-Anlage; Default `false` über DB-Spalte).
|
||||
- Test: `tests/Feature/AboCreateOneTimeItemsTest.php` („persistiert die Cart-Option abo_addon…", via `PaymentHelper::makeShoppingOrder` mit echtem Yard).
|
||||
|
||||
- `app/Repositories/CheckoutRepository.php` (Order-Item-Erzeugung, beide Zweige Update+Create um `'is_abo_addon' => (bool)($item->options->abo_addon ?? false)` ergänzen).
|
||||
- `app/Services/PaymentHelper.php::makeShoppingOrder()` analog.
|
||||
- Prüfen: `app/Http/Controllers/Api/ShoppingUserController.php:700` (API-Order-Items) – vermutlich nicht abo-relevant, dokumentieren.
|
||||
- **Test:** Feature-Test – Yard mit `abo_addon`-Row → ShoppingOrder → Order-Item trägt `is_abo_addon=true`; normale Rows bleiben `false`.
|
||||
|
||||
### Phase 2 — Abo-Anlage: Einmalprodukte NICHT in `user_abo_items` übernehmen
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Umsetzung:**
|
||||
- `AboHelper::createAboItems()`: überspringt `is_abo_addon`-Positionen.
|
||||
- `AboHelper::ensureUserAboItemsFromLatestOrder()`: ebenso (Repair-Pfad).
|
||||
- `AboHelper::createNewAbo()`: `amount = shopping_payment->amount − Addon-Brutto (price × qty, in Cent)`, konsistent zur Definition von `makeOrderYard()` (Abo-Artikel + Versand). Versand bleibt der Erstbestellungs-Versand, wird bei der ersten Cron-Ausführung ohnehin neu berechnet.
|
||||
- Tests: `tests/Feature/AboCreateOneTimeItemsTest.php` (5 Tests grün); Regression: `PaymentAboCallbackCreatesUserAboTest`, `AboHelperEnsureUserAboItemsFromLatestOrderTest`, `StripeAboTest`, `StripeCheckoutTest`, `AboMakeOrderOneTimeTest` (43 Tests grün).
|
||||
|
||||
- `AboHelper::createAboItems()` (`AboHelper.php:483`): Positionen mit `is_abo_addon=true` überspringen.
|
||||
- `AboHelper::ensureUserAboItemsFromLatestOrder()` (`AboHelper.php:527`): ebenfalls überspringen (Repair-Pfad).
|
||||
- `AboHelper::createNewAbo()`: `amount` **ohne** Addon-Anteil setzen (Summe der `aboOnly()`-Positionen konsistent zur Betragsdefinition von `makeOrderYard()`; genaue Formel bei Umsetzung an `makeOrderYard()` ausrichten und im Plan dokumentieren).
|
||||
- **Tests:** Abo-Anlage aus Order mit Addon-Items → `user_abo_items` enthält nur Abo-Produkte; `amount` ohne Addons; Repair-Command-Pfad ebenso.
|
||||
|
||||
### Phase 3 — Sales Center: Einmalprodukte in der Abo-Bestellstrecke
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Umsetzung:**
|
||||
- `ProductOrderContext::oneTimeShowOnIds()` neu (`['3']`, wie Bestandsfeature; gilt lt. A5 auch für `abo-me`).
|
||||
- `User/OrderController@datatable`: Modus `abo_addon=1` (nur bei `is_abo`) → Einmal-Sortiment; `product`-Spalte sucht die Addon-Cart-Zeile per Yard-`search` (eigene rowId, da Optionen in die rowId einfließen), Inputs `onetime_qty_*` + `data-abo-addon="1"`.
|
||||
- `User/OrderController@handleUpdateCart`: Request-Flag `abo_addon` → Sortiment-Prüfung gegen `oneTimeShowOnIds()`, Cart-Option `abo_addon=true` (getrennte Zeile). Gewichts-/Comp-Logik unverändert.
|
||||
- Neues Partial `resources/views/user/order/_onetime_list.blade.php` (eigene Datatable `#datatables-onetime-list`), eingebunden in `list_me`/`list_ot-member`/`list_ot-customer` bei `$is_abo`; Preisspalten je Flow (me = EK, ot = VK).
|
||||
- `yard_view_form.blade.php`: Addon-Zeilen mit Badge `abo.onetime_badge_first_delivery`; Mengen-Controls und Entfernen-Link tragen `data-abo-addon`.
|
||||
- `public/js/iq-shopping-cart.js`: zweite Tabelle, Addon-Flag-Weitergabe (`abo_addon` im updateCart-Request), zielgenaue Input-/Zeilen-Auflösung via `data-abo-addon`.
|
||||
- i18n: `abo.onetime_create_hl`, `abo.onetime_create_info`, `abo.onetime_badge_first_delivery` (de/en/es/fr).
|
||||
- Tests: `tests/Feature/AboCreateOneTimeCartTest.php` (6 Tests grün) — Controller direkt aufgerufen (Domain-Routing hat Catch-All → kein HTTP-Roundtrip; etabliertes Muster „route() vermeiden").
|
||||
- **Hinweis:** `datatable()`-Query nutzt `JSON_CONTAINS` (MySQL) → auf SQLite nicht testbar; Sortiment-Logik ist über `oneTimeShowOnIds()`-Test + Cart-Ablehnungstests abgedeckt.
|
||||
|
||||
- `User/OrderController@list` (Abo-Flows): zusätzlicher Bereich „Einmalprodukte (nur erste Lieferung)" – zweite Produktliste/Datatable auf Normalsortiment (`'3'`, bei `abo-me` ggf. `'2'`, siehe A5), UI-Muster der bestehenden Einmalprodukte-Ansicht (`modal_abo_onetime_products` / Datatable `OrderController@datatable` mit neuem Kontext-Parameter).
|
||||
- `handleUpdateCart()` (`OrderController.php:816-885`): Request-Flag `abo_addon` akzeptieren; Kontextprüfung dann gegen Normalsortiment statt `['12','13']`; Cart-Add mit Option `abo_addon=true`. Gewichts-/Comp-Logik läuft unverändert über den Yard.
|
||||
- `ProductOrderContext`: Hilfsmethode für den Addon-Kontext (z. B. `allowedOneTimeShowOnIds(string $for)`), keine Aufweichung der bestehenden Abo-Prüfung.
|
||||
- Warenkorb-Ansicht (`user/order/yard_view_form` + Zusammenfassung): Addon-Zeilen visuell kennzeichnen/gruppieren („einmalig, nur erste Lieferung"), analog `AboOrderCart::getSplitSummary()`-Muster.
|
||||
- **Tests:** Add erlaubt mit Flag + `show_on 3`; ohne Flag weiterhin abgelehnt; Abo-Produkt mit Flag abgelehnt.
|
||||
|
||||
### Phase 4 — Portal-Wizard: Einmalprodukte-Schritt
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Entscheidung:** Kein neuer Wizard-Schritt (Step-Nummerierung + back-Logik bleiben unangetastet) — Einmalprodukte sind ein **optionaler Abschnitt im Upgrade-Schritt** (Step 3-View), verarbeitet beim Submit zu Step 4.
|
||||
|
||||
**Umsetzung:**
|
||||
- `Portal/AboController`: `onetime_products` (aktiv, `oneTimeShowOnIds()`) in `prepareAboCreateData()`; case 4 ruft zusätzlich `oneTimeProductsToCart()`; neue Methoden `oneTimeProductsToCart()`/`addOneTimeProductToCart()` (Sortiment-Prüfung, Cart-Option `abo_addon=true`, qty 0 = entfernen); `addProductToCart()` (Basis/Upgrade) schließt Einmal-Zeilen aus der Warenkorb-Suche aus.
|
||||
- `AboHelper::aboHasBaseProduct()`: überspringt Einmal-Zeilen (ein Addon mit `show_on ['3','12']` zählte sonst fälschlich als Basis-Produkt).
|
||||
- Views: neue Komponente `componenten/onetime-product-table.blade.php` (addon-bewusste Warenkorb-Suche, Inputs `onetime_product_qty[id]`); Abschnitt in `_create_upgrade_products.blade.php` (Überschrift + Info-Alert); `_create_check.blade.php` zeigt Addon-Zeilen mit Badge `abo.onetime_badge_first_delivery` + Info-Alert.
|
||||
- i18n: `abo.onetime_product` (de/en/es/fr).
|
||||
- Tests: `tests/Feature/AboPortalCreateOneTimeTest.php` (5 Tests grün, private Methoden via Reflection — Wizard braucht Customer-Guard + Session, HTTP-Roundtrip scheitert am Domain-Catch-All). Blade-Kompilierung via `view:cache` verifiziert, JS via `node --check`.
|
||||
|
||||
- `Portal/AboController::prepareAboCreateData()`: neuer optionaler Schritt zwischen Upgrade-Produkten und Zusammenfassung (Step-Nummerierung + `action=back`-Logik anpassen) ODER Abschnitt im bestehenden Upgrade-Schritt – Entscheidung nach UI-Sichtung von `my_abo_create.blade.php`.
|
||||
- Produktquelle: `show_on='3'` (aktiv), Darstellung analog Basis-/Upgrade-Kacheln mit Mengenfeld (`onetime_product_qty[]`).
|
||||
- `addProductToCart()`-Variante mit Option `abo_addon=true` (Preislogik identisch `abo-ot-customer`-Zweig; vgl. `buildOneTimeSnapshot()`-Rundung `round(price, 1)` – konsistent halten).
|
||||
- Zusammenfassung (Step 4/5) + Checkout-Seite: getrennte Anzeige „Abo-Produkte" vs. „Einmalige Artikel (nur erste Lieferung)" inkl. Hinweistext, dass diese nicht Teil der wiederkehrenden Lieferung sind.
|
||||
- **Tests:** Wizard-Flow mit Einmalprodukt → Yard enthält Row mit `abo_addon`; Basis-Produkt-Pflicht unverändert; Step-Navigation (back) intakt.
|
||||
|
||||
### Phase 5 — Rechnung/Lieferschein & E-Mails verifizieren
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Umsetzung:**
|
||||
- Erwartung bestätigt: Gruppierung greift automatisch über `is_abo_addon` (bestehende PDF-Logik). Integrationstest in `AboCreateOneTimeItemsTest` („gruppiert Erstrechnung und Lieferschein…"): Anlage-Order → `pdf.invoice-detail` + `pdf.delivery-detail` zeigen beide Blöcke, Abo vor Einmal.
|
||||
- Bestellbestätigungs-Mails: keine Änderung (Positionsreihenfolge folgt der Order; bewusst klein gehalten — bei Bedarf später nachschärfen).
|
||||
|
||||
- Erwartung: Erstrechnung + Lieferschein gruppieren automatisch (bestehende `is_abo_addon`-Logik). **Test** analog `AboOneTimeInvoiceGroupingTest` für eine Anlage-Order.
|
||||
- Bestellbestätigungs-Mail(s) der Checkout-Strecke prüfen: Addon-Kennzeichnung sinnvoll? (Klein halten; ggf. nur Reihenfolge wie PDF.)
|
||||
- Hinweis Test-System: Rechnungen entstehen nur in Produktion – auf `.test` mit `sales:preview-invoice` prüfen.
|
||||
|
||||
### Phase 6 — Folge-Logik & Rand-Pfade
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
**Prüfergebnisse:**
|
||||
- `IncentiveTracker::trackAboActivated`: zählt das **Abo selbst** (`points_abo_onetime` je Incentive), keine Order-Punkte → von Addons unberührt.
|
||||
- Provision/Punkte: Einmalprodukte zählen wie normale Positionen der Erstbestellung (Order-`points` inkl. Addons — gewollt, wie beim Bestandsfeature in der Folgelieferung); Abo-eigene Auswertungen (`user_abo_items`, `amount`) bleiben Addon-frei (P2).
|
||||
- `RepairMissingAboFromOrders`: nutzt `createNewAbo` → P2-Fix greift automatisch.
|
||||
- `CreateAboReplacementOrder`: übernahm das Flag nicht → `is_abo_addon` wird jetzt in `calculateReplacement()`/`createReplacementItems()` propagiert (Ersatzrechnung behält Gruppierung). Regressionstest grün.
|
||||
- Stripe-Webhook-Pfad: selber `createNewAbo`-Code, `StripeAboTest`/`StripeCheckoutTest` grün.
|
||||
- Recovery-Checkout: legt kein Abo an; Variante A schreibt nichts in `user_abo_one_time_items` → keine Interferenz mit `purgeAfterExecution`.
|
||||
- `Api/ShoppingUserController` (API-Order-Items): keine Abo-Anlage, DB-Default `false` korrekt — unverändert.
|
||||
|
||||
- **Provision/Punkte:** Einmalprodukte der Erstbestellung zählen wie normale Verkaufspositionen der Order (Punkte/Provision der Erstbestellung) – Verhalten dokumentieren; sicherstellen, dass keine Abo-spezifische Auswertung (`user_abo_items`, `amount`) sie mitzählt.
|
||||
- `IncentiveTracker::trackAboActivated` prüfen (nutzt Order – zählt sie Order-Punkte? Falls ja: Entscheidung dokumentieren).
|
||||
- `CreateAboReplacementOrder` (Ersatzbestellung aus `user_abo_items`): unberührt, da Addons dort nie ankommen – kurz verifizieren.
|
||||
- Stripe-Webhook-Pfad (`createNewAbo` via Webhook) läuft durch dieselben Fixes – Test mit Stripe-Payment-Fake, falls vorhandene Test-Infrastruktur das hergibt.
|
||||
- Recovery-Checkout (`advanceAboAfterRecoveryPayment`): kein neues Abo, keine Anlage-Addons – unberührt, verifizieren dass `purgeAfterExecution` keine Anlage-Interferenz hat (Variante A schreibt nichts in `user_abo_one_time_items`).
|
||||
|
||||
### Phase 7 — i18n, Pint, Gesamttest
|
||||
|
||||
**Status:** ERLEDIGT (2026-07-21)
|
||||
|
||||
- Neue Keys `abo.onetime_create_hl`, `abo.onetime_create_info`, `abo.onetime_badge_first_delivery`, `abo.onetime_product` in de/en/es/fr (de in du-Form).
|
||||
- **Zusatzfund:** `abo.php` war in es/fr bereits VOR dieser Umsetzung nicht synchron — der komplette Kreditkarten-/Zahlartwechsel-Block (41 Keys, `card_*`/`payment_method_change_*`, aus dem CC-Update-/Stripe-Feature) fehlte. In es/fr nachübersetzt → `abo.php` jetzt synchron.
|
||||
- **Nachtrag (2026-07-21, auf Kundenwunsch):** Auch die restlichen vorbestehenden Lücken aus dem Stripe-/Widerrufs-Workstream wurden synchronisiert: `navigation.php` (es/fr: `my_statistics`, `team_statistics`, `withdrawals`), `payment.php` (es/fr: 15 Stripe-/Wartungs-Keys), `weborder.php` (es/fr: 17 Stripe-Zahlart-Keys ergänzt, 4 PAYONE-Altlasten `payment_method_direct/sepa` entfernt), `withdrawal.php` (es/fr: komplett neu übersetzt, es = usted, fr = vous). `LocalizationSyncTest` + `FrenchLocalizationTest` + `LocalizationMiddlewareTest` grün (8 Tests, 286 Assertions).
|
||||
- `./vendor/bin/pint --dirty` → pass; Blade-Kompilierung (`view:cache`) + `node --check` fürs JS grün.
|
||||
- Gesamtlauf: 122 Tests grün (neue 16 + Einmalprodukte-Bestandssuite + Checkout-/Stripe-/Repair-Regressionen).
|
||||
|
||||
---
|
||||
|
||||
## 9. Umsetzungs-Zusammenfassung (2026-07-21)
|
||||
|
||||
Alle 7 Phasen umgesetzt. Kern-Mechanik: Cart-Option `abo_addon` → `shopping_order_items.is_abo_addon` (Checkout persistiert jetzt) → PDF-Gruppierung automatisch; `createAboItems`/`ensureUserAboItemsFromLatestOrder` überspringen Addons; `user_abos.amount` ohne Addon-Anteil. Kein neues Datenmodell, keine Migration. Neue Tests: `AboCreateOneTimeItemsTest` (6), `AboCreateOneTimeCartTest` (6), `AboPortalCreateOneTimeTest` (5).
|
||||
|
||||
---
|
||||
|
||||
## 6. Risiken & Stolperfallen
|
||||
|
||||
1. **Die Kopier-Falle ist der kritischste Punkt:** Ohne Phase 2 würden Einmalprodukte zu dauerhaften Abo-Positionen (und ab der ersten Cron-Ausführung monatlich berechnet). Phase 2 daher **vor** jeder UI-Phase umsetzen und testen.
|
||||
2. **`user_abos.amount`**-Definition exakt an `makeOrderYard()` ausrichten, sonst springt der angezeigte Abo-Betrag bei der ersten Ausführung.
|
||||
3. **Cart-Persistenz zwischen Steps:** Beide Strecken re-initialisieren den Yard pro Step (`initCustomerYard`) – Addon-Rows müssen Step-Wechsel und `action=back` überleben (Option hängt an der Row, sollte gegeben sein; testen).
|
||||
4. **Kontextwechsel-Reset:** `OrderController@list:194-199` zerstört den Yard bei Sortimentswechsel (`allowedShowOnIds`-Vergleich) – neue Addon-Logik darf diesen Vergleich nicht verfälschen.
|
||||
5. **Gewichtslimit/Versandkosten:** Addon-Rows zählen ins Gesamtgewicht (gewollt, wie im Bestand) – Max-Gewicht-Prüfung greift automatisch (`exceedsCartMaxWeight`).
|
||||
6. **Keine Vermischung mit dem Bestandsfeature:** `user_abo_one_time_items`, Fenster-Logik, `confirm`-Workflow und Reminder bleiben unangetastet (gelten weiter nur für laufende Abos).
|
||||
7. **Homeparty/Collection-Sonderzweige** in `CheckoutRepository` (setzen `product_id = null`): Abo-Anlage läuft nicht über diese Zweige, aber beim Ergänzen des Flags nicht hineinpfuschen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Betroffene Dateien (Vorschau)
|
||||
|
||||
**Geändert:**
|
||||
- `app/Repositories/CheckoutRepository.php`, `app/Services/PaymentHelper.php` (P1)
|
||||
- `app/Services/AboHelper.php` (`createAboItems`, `ensureUserAboItemsFromLatestOrder`, `createNewAbo`/amount) (P2)
|
||||
- `app/Http/Controllers/User/OrderController.php` (P3)
|
||||
- `app/Services/ProductOrderContext.php` (P3)
|
||||
- `resources/views/user/order/*` (P3)
|
||||
- `app/Http/Controllers/Portal/AboController.php` (P4)
|
||||
- `resources/views/portal/abo/my_abo_create.blade.php` (P4)
|
||||
- `resources/lang/{de,en,es,fr}/…` (P7)
|
||||
|
||||
**Neu (Tests):**
|
||||
- `tests/Feature/AboCreateOneTimeItemsTest.php` (Kern: Flag-Persistenz, createAboItems-Skip, amount)
|
||||
- `tests/Feature/AboCreateOneTimeUiTest.php` (Strecken/Views), ggf. aufgeteilt
|
||||
|
||||
---
|
||||
|
||||
## 8. Umsetzungsreihenfolge
|
||||
|
||||
P1 + P2 (Backend-Fundament + Kopier-Falle, isoliert testbar) → P3 (Sales Center) → P4 (Portal) → P5 (PDF/Mail-Verifikation) → P6 (Rand-Pfade) → P7 (i18n/Abschluss).
|
||||
|
||||
Vor P1: **Annahme A1 (Variante A) und A2/A5 bestätigen lassen.**
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
|
||||
var IqShoppingCart = {
|
||||
table: "#datatables-order-list",
|
||||
// Zweite Produktliste (Abo-Anlage): Einmalprodukte, deren Cart-Zeilen die Option abo_addon tragen
|
||||
table_onetime: "#datatables-onetime-list",
|
||||
btn_add: '.add-product-basket',
|
||||
btn_remove: '.remove-product-basket',
|
||||
card_holder: '#holder_html_view_card',
|
||||
|
|
@ -39,17 +41,25 @@ var IqShoppingCart = {
|
|||
},
|
||||
reInit: function (){
|
||||
var _self = this;
|
||||
$(_self.table).find(_self.btn_add).on('click', function(){
|
||||
_self.add_product($(this))
|
||||
});
|
||||
[_self.table, _self.table_onetime].forEach(function(tableSel){
|
||||
$(tableSel).find(_self.btn_add).off('click').on('click', function(){
|
||||
_self.add_product($(this), tableSel)
|
||||
});
|
||||
|
||||
$(_self.table).find(_self.btn_remove).on('click', function(){
|
||||
_self.remove_product($(this))
|
||||
});
|
||||
$(_self.table).find(_self.table_input).off('change').on('change', function(){
|
||||
_self.update_input_table($(this));
|
||||
$(tableSel).find(_self.btn_remove).off('click').on('click', function(){
|
||||
_self.remove_product($(this), tableSel)
|
||||
});
|
||||
$(tableSel).find(_self.table_input).off('change').on('change', function(){
|
||||
_self.update_input_table($(this), tableSel);
|
||||
});
|
||||
});
|
||||
},
|
||||
isAddonElement: function (_obj){
|
||||
return _obj.data('abo-addon') == 1;
|
||||
},
|
||||
qtyInputName: function (product_id, isAddon){
|
||||
return (isAddon ? 'onetime_qty_' : 'product_qty_') + product_id;
|
||||
},
|
||||
showInit: function (){
|
||||
var _self = this;
|
||||
$(_self.btn_clear).on('click', function (){
|
||||
|
|
@ -67,7 +77,7 @@ var IqShoppingCart = {
|
|||
});
|
||||
$(_self.remove_item).on('click', function(event){
|
||||
event.preventDefault();
|
||||
_self.update_cart_database($(this).data('product-id'), 0);
|
||||
_self.update_cart_database($(this).data('product-id'), 0, _self.isAddonElement($(this)));
|
||||
});
|
||||
if(_self.is_for === 'me' || _self.is_for === 'abo-me'){
|
||||
$('input[name^="'+_self.comp_products+'"]').on('change', function(){
|
||||
|
|
@ -82,19 +92,19 @@ var IqShoppingCart = {
|
|||
_self.performRequest({shipping_country_id: id, shipping_is_for: is_for, action: 'updateShippingCountry'})
|
||||
.done(_self.refreshItemsAndView);
|
||||
},
|
||||
update_input_table: function (_obj){
|
||||
update_input_table: function (_obj, tableSel){
|
||||
var _self = this;
|
||||
var qty = parseInt(_obj.val());
|
||||
qty = _self.checkNumber(qty);
|
||||
_obj.val(qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty, _self.isAddonElement(_obj));
|
||||
},
|
||||
update_input_cart: function (_obj){
|
||||
var _self = this;
|
||||
var qty = parseInt(_obj.val());
|
||||
qty = _self.checkNumber(qty);
|
||||
_obj.val(qty);
|
||||
_self.update_cart_database(_obj.data('product-id'), qty);
|
||||
_self.update_cart_database(_obj.data('product-id'), qty, _self.isAddonElement(_obj));
|
||||
},
|
||||
/**
|
||||
* Plus-/Minus-Buttons im Warenkorb: Menge nicht optimistisch erhöhen, sondern
|
||||
|
|
@ -104,41 +114,46 @@ var IqShoppingCart = {
|
|||
change_cart_qty: function (_obj, delta){
|
||||
var _self = this;
|
||||
var productId = _obj.data('product-id');
|
||||
var input = $(_self.card_holder).find('.cart-input-event-onchange[data-product-id="'+productId+'"]');
|
||||
var isAddon = _self.isAddonElement(_obj);
|
||||
var input = $(_self.card_holder).find('.cart-input-event-onchange[data-product-id="'+productId+'"]').filter(function(){
|
||||
return IqShoppingCart.isAddonElement($(this)) === isAddon;
|
||||
});
|
||||
var qty = _self.checkNumber(parseInt(input.val(), 10) + delta);
|
||||
_self.update_cart_database(productId, qty);
|
||||
_self.update_cart_database(productId, qty, isAddon);
|
||||
},
|
||||
update_comp_product: function (_obj){
|
||||
var _self = this;
|
||||
_self.performRequest({comp_product_id: _obj.val(), comp_num: _obj.data('comp_num'), count_comp_products: $('input[name="'+_self.count_comp_products+'"]').val(), action: 'updateCompProduct'})
|
||||
.done(_self.refreshItemsAndView);
|
||||
},
|
||||
add_product: function (_obj){
|
||||
add_product: function (_obj, tableSel){
|
||||
var _self = this;
|
||||
var input = $(_self.table).find('input[name="product_qty_'+_obj.data('product-id')+'"]');
|
||||
var isAddon = _self.isAddonElement(_obj);
|
||||
var input = $(tableSel || _self.table).find('input[name="'+_self.qtyInputName(_obj.data('product-id'), isAddon)+'"]');
|
||||
var qty = parseInt(input.val()) + 1;
|
||||
qty = _self.checkNumber(qty);
|
||||
input.val(qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty, isAddon);
|
||||
},
|
||||
remove_product: function (_obj){
|
||||
remove_product: function (_obj, tableSel){
|
||||
var _self = this;
|
||||
var input = $(_self.table).find('input[name="product_qty_'+_obj.data('product-id')+'"]');
|
||||
var isAddon = _self.isAddonElement(_obj);
|
||||
var input = $(tableSel || _self.table).find('input[name="'+_self.qtyInputName(_obj.data('product-id'), isAddon)+'"]');
|
||||
var qty = parseInt(input.val()) - 1;
|
||||
if(qty < 0){
|
||||
qty = 0;
|
||||
}
|
||||
input.val(qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty);
|
||||
_self.update_cart(_obj.data('product-id'), qty, isAddon);
|
||||
},
|
||||
update_cart_database: function (product_id, qty){
|
||||
update_cart_database: function (product_id, qty, isAddon){
|
||||
var _self = this;
|
||||
_self.performRequest({product_id: product_id, qty: qty, action: 'updateCart'})
|
||||
_self.performRequest({product_id: product_id, qty: qty, abo_addon: isAddon ? 1 : 0, action: 'updateCart'})
|
||||
.done(_self.refreshDatabaseAndView);
|
||||
},
|
||||
update_cart: function (product_id, qty){
|
||||
update_cart: function (product_id, qty, isAddon){
|
||||
var _self = this;
|
||||
_self.performRequest({product_id: product_id, qty: qty, action: 'updateCart'})
|
||||
_self.performRequest({product_id: product_id, qty: qty, abo_addon: isAddon ? 1 : 0, action: 'updateCart'})
|
||||
.done(_self.refreshItemsAndView);
|
||||
},
|
||||
/**
|
||||
|
|
@ -225,7 +240,9 @@ var IqShoppingCart = {
|
|||
$(_self.card_holder).html(data.html_card);
|
||||
$(_self.comp_holder).html(data.html_comp);
|
||||
|
||||
var input = $(_self.table).find('input[name="product_qty_'+data.data.product_id+'"]');
|
||||
var isAddon = data.data && data.data.abo_addon == 1;
|
||||
var tableSel = isAddon ? _self.table_onetime : _self.table;
|
||||
var input = $(tableSel).find('input[name="'+_self.qtyInputName(data.data.product_id, isAddon)+'"]');
|
||||
input.val(data.data.qty);
|
||||
_self.showInit();
|
||||
_self.updateAboIntervalWarning();
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ return [
|
|||
'abo_subtotal' => 'Zwischensumme Abo-Produkte',
|
||||
'abo_next_delivery_total' => 'Gesamtbetrag der nächsten Lieferung',
|
||||
'onetime_empty' => 'Noch keine einmaligen Produkte hinzugefügt.',
|
||||
'onetime_product' => 'Einmaliges Produkt',
|
||||
'onetime_create_hl' => 'Einmalige Produkte zur ersten Lieferung hinzufügen',
|
||||
'onetime_create_info' => 'Diese Produkte werden nur mit der ersten Abo-Lieferung verschickt und mit dem Abo-Abschluss bezahlt. Sie gehören nicht zum wiederkehrenden Abo und werden bei den Folgelieferungen nicht erneut berechnet.',
|
||||
'onetime_badge_first_delivery' => 'Einmalig (nur erste Lieferung)',
|
||||
'onetime_badge' => 'Einmalig',
|
||||
'combined_summary_hl' => 'Gesamtsumme der nächsten Lieferung',
|
||||
'create_abo' => 'Abo erstellen',
|
||||
|
|
|
|||
|
|
@ -137,6 +137,10 @@ return [
|
|||
'abo_subtotal' => 'Subtotal subscription products',
|
||||
'abo_next_delivery_total' => 'Total amount of next delivery',
|
||||
'onetime_empty' => 'No one-time products added yet.',
|
||||
'onetime_product' => 'One-time product',
|
||||
'onetime_create_hl' => 'Add one-time products to your first delivery',
|
||||
'onetime_create_info' => 'These products are shipped with the first subscription delivery only and are paid together with the subscription order. They are not part of the recurring subscription and will not be charged again with future deliveries.',
|
||||
'onetime_badge_first_delivery' => 'One-time (first delivery only)',
|
||||
'onetime_badge' => 'One-time',
|
||||
'combined_summary_hl' => 'Total of next delivery',
|
||||
'create_abo' => 'Create subscription',
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ return [
|
|||
'abo_subtotal' => 'Subtotal productos de suscripción',
|
||||
'abo_next_delivery_total' => 'Importe total de la próxima entrega',
|
||||
'onetime_empty' => 'Aún no se han añadido productos únicos.',
|
||||
'onetime_product' => 'Producto único',
|
||||
'onetime_create_hl' => 'Añadir productos únicos al primer envío',
|
||||
'onetime_create_info' => 'Estos productos se envían solo con la primera entrega de la suscripción y se pagan junto con el pedido de suscripción. No forman parte de la suscripción recurrente y no se cobrarán de nuevo en futuras entregas.',
|
||||
'onetime_badge_first_delivery' => 'Único (solo primer envío)',
|
||||
'onetime_badge' => 'Único',
|
||||
'combined_summary_hl' => 'Total de la próxima entrega',
|
||||
'create_abo' => 'Crear suscripción',
|
||||
|
|
@ -249,4 +253,47 @@ return [
|
|||
'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.',
|
||||
|
||||
// Kreditkarte / Zahlart aktualisieren (Abo)
|
||||
'update_card_title' => 'Actualizar tarjeta de crédito',
|
||||
'card_change_history' => 'Historial de cambios',
|
||||
'current_card' => 'Tarjeta actual',
|
||||
'card_update_intro' => 'Guarde una nueva tarjeta de crédito para esta suscripción. Puede realizarse una autorización de verificación de 0,01 EUR que no se cobra. El próximo cargo se realizará con la nueva tarjeta.',
|
||||
'card_security_notice' => 'Sus datos de tarjeta no se almacenan aquí. Se transmiten cifrados por SSL directamente a nuestro proveedor de pagos PAYONE solo para su verificación.',
|
||||
'card_holder_required' => 'Introduzca el nombre del titular de la tarjeta.',
|
||||
'card_number_invalid' => 'Introduzca un número de tarjeta de crédito válido.',
|
||||
'card_expiry_invalid' => 'Introduzca una fecha de caducidad válida.',
|
||||
'card_cvc_invalid' => 'Introduzca un código de seguridad (CVC) válido.',
|
||||
'card_type_invalid' => 'Seleccione un tipo de tarjeta válido (Visa o Mastercard).',
|
||||
'card_update_success' => 'Su tarjeta de crédito se ha actualizado. El próximo cargo se realizará con la nueva tarjeta.',
|
||||
'card_update_failed' => 'No se pudo actualizar la tarjeta de crédito. Inténtelo de nuevo.',
|
||||
'card_update_not_allowed' => 'La tarjeta de crédito no se puede actualizar para esta suscripción.',
|
||||
'card_update_link_button' => 'Enviar enlace de actualización al cliente',
|
||||
'card_update_link_sent' => 'El enlace de actualización se ha enviado al cliente.',
|
||||
'card_update_link_invalid' => 'Este enlace no es válido o ha caducado.',
|
||||
'card_update_link_not_allowed' => 'No se puede enviar un enlace de actualización para esta suscripción.',
|
||||
'card_update_link_mail_subject' => 'Actualice la tarjeta de crédito de su suscripción',
|
||||
'card_update_link_mail_intro' => 'Actualice la tarjeta de crédito de su suscripción a través del siguiente enlace seguro. Sus datos de tarjeta se transmiten cifrados por SSL únicamente a nuestro proveedor de pagos PAYONE.',
|
||||
'card_update_link_mail_button' => 'Actualizar tarjeta de crédito',
|
||||
'card_update_link_mail_hint' => 'Si el botón no funciona, copie este enlace en su navegador:',
|
||||
'card_update_done_success_title' => 'Tarjeta de crédito actualizada',
|
||||
'card_update_done_success_copy' => 'Gracias. El próximo cargo se realizará con su nueva tarjeta.',
|
||||
'card_update_done_failed_title' => 'Actualización fallida',
|
||||
'card_update_done_failed_copy' => 'No se pudo actualizar la tarjeta de crédito. Solicite un nuevo enlace si es necesario.',
|
||||
'card_update_portal_hint' => 'También encontrará esta suscripción en su portal de clientes. Allí puede iniciar sesión y gestionar usted mismo su método de pago y su suscripción en cualquier momento.',
|
||||
'card_update_to_portal' => 'Ir al portal de clientes',
|
||||
'payment_method' => 'Método de pago',
|
||||
'current_payment_method' => 'Método de pago actual',
|
||||
'update_payment_method' => 'Actualizar método de pago',
|
||||
'payment_method_change_title' => 'Cambiar método de pago',
|
||||
'payment_method_change_intro' => 'Elija un nuevo método de pago para esta suscripción en la página de pago segura, por ejemplo si su tarjeta ha caducado o desea pagar de otra forma en el futuro.',
|
||||
'payment_method_change_no_charge' => 'No se cobrará nada: el nuevo método de pago se aplica a partir del próximo pago de la suscripción.',
|
||||
'payment_method_change_button' => 'Elegir nuevo método de pago',
|
||||
'payment_method_change_success' => 'Su método de pago se ha actualizado. El próximo pago se realizará con el nuevo método.',
|
||||
'payment_method_change_failed' => 'No se pudo cambiar el método de pago. Inténtelo de nuevo.',
|
||||
'payment_method_change_done_title' => 'Método de pago actualizado',
|
||||
'payment_method_change_done_failed_copy' => 'No se pudo cambiar el método de pago. Solicite un nuevo enlace si es necesario.',
|
||||
'payment_method_change_link_mail_subject' => 'Actualice el método de pago de su suscripción',
|
||||
'payment_method_change_link_mail_intro' => 'Actualice el método de pago de su suscripción a través del siguiente enlace seguro. Elegirá su nuevo método de pago directamente en la página segura de nuestro proveedor de pagos Stripe; no se cobrará nada.',
|
||||
'payment_method_change_link_mail_button' => 'Actualizar método de pago',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -89,4 +89,8 @@ return [
|
|||
'team_customers' => 'Clientes del Equipo',
|
||||
'payment_monitor' => 'Monitor de Pagos',
|
||||
'payment_monitor_management' => 'Monitor de Pagos (Dir.)',
|
||||
|
||||
'my_statistics' => 'Mi estadística',
|
||||
'team_statistics' => 'Estadística del equipo',
|
||||
'withdrawals' => 'Desistimientos',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -196,4 +196,20 @@ return [
|
|||
'packstation_alert_footer' => 'El NÚMERO de Packstation tiene 3 dígitos (100-999, en el letrero amarillo). El número de correo DHL tiene 6-10 dígitos y va en el campo separado arriba.',
|
||||
'' => '',
|
||||
'unknown' => 'desconocido',
|
||||
|
||||
'prepayment_payone' => 'Pago por adelantado (PAYONE)',
|
||||
'prepayment_no_clearing_data' => 'Los datos bancarios aún no están disponibles: el pago todavía no se ha registrado correctamente en el proveedor de pagos.',
|
||||
'checkout_maintenance_title' => 'Dificultades técnicas',
|
||||
'checkout_maintenance' => 'Actualmente no es posible realizar pedidos debido a dificultades técnicas. Volveremos en breve; inténtelo de nuevo más tarde.',
|
||||
'abo_payments_frozen_title' => 'Pagos de suscripción temporalmente no disponibles',
|
||||
'abo_payments_frozen' => 'Los pagos de suscripción no son posibles en este momento. Estamos trabajando intensamente en una solución técnica; inténtelo de nuevo más tarde. Sus suscripciones y pedidos no se perderán.',
|
||||
'stripe_online' => 'Pago en línea',
|
||||
'stripe_online_short' => 'Pago en línea (Stripe)',
|
||||
'stripe_online_copy' => 'Será redirigido a la página de pago segura, donde podrá elegir su método de pago.',
|
||||
'stripe_online_methods' => 'Tarjeta de crédito, PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay y muchos más',
|
||||
'stripe_order_label' => 'Pedido :reference',
|
||||
'stripe_unavailable' => 'El pago en línea no está disponible en este momento. Inténtelo de nuevo en un momento.',
|
||||
'stripe_abo_hint' => 'Su método de pago se guarda de forma segura en nuestro proveedor de pagos Stripe y se utiliza para los próximos cobros de la suscripción.',
|
||||
'stripe_abo_payment_method_missing' => 'No hay ningún método de pago válido guardado para esta suscripción. Actualice su método de pago.',
|
||||
'stripe_abo_authentication_required' => 'Su banco requiere una confirmación del pago. Complete el pago desde su página de suscripción.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -21,14 +21,10 @@ return [
|
|||
'only_missing_free_shipping_costs_from' => 'Ya sólo te queda :value € para tu envío gratis.',
|
||||
'payment_method_creditcard' => 'tarjeta de crédito',
|
||||
'payment_method_creditcard_copy' => 'El importe se deducirá de su tarjeta de crédito tan pronto como se envíe el pedido. El proceso de la tarjeta de crédito se procesa a través de nuestro proveedor de servicios de pago PAYONE.',
|
||||
'payment_method_direct' => 'transferencia bancaria instantánea',
|
||||
'payment_method_direct_copy' => 'Después de una verificación exitosa, será redirigido a la página de pago seguro de PAYONE para que las transferencias SOFORT continúen con el pago. Después del pago exitoso, no cierre el navegador hasta que haya sido redirigido nuevamente a la tienda.',
|
||||
'payment_method_paypal' => 'pago con Paypal',
|
||||
'payment_method_paypal_copy' => 'Después de una verificación exitosa, será redirigido a la página de pago seguro de Paypal para continuar con el pago. Después del pago exitoso, no cierre el navegador hasta que haya sido redirigido nuevamente a la tienda.',
|
||||
'payment_method_prepayment' => 'pago por adelantado',
|
||||
'payment_method_prepayment_copy' => 'Una vez que hayas enviado el pedido, recibirás un correo electrónico con los datos bancarios para realizar el pago. Una vez recibido el importe, se enviarán sus productos.',
|
||||
'payment_method_sepa' => 'débito directo SEPA',
|
||||
'payment_method_sepa_copy' => 'Se le cobrará a su cuenta por este pedido después de enviarlo. El débito directo SEPA se procesa a través de nuestro proveedor de servicios de pago PAYONE.',
|
||||
'payment_methods' => 'métodos de pago',
|
||||
'plus_shipping_cost' => 'más envío y manipulación',
|
||||
'price_net' => 'precios netos',
|
||||
|
|
@ -42,4 +38,22 @@ return [
|
|||
'to_product' => 'al producto',
|
||||
'to_shopping_cart' => 'a la canasta de productos',
|
||||
'' => '',
|
||||
|
||||
'payment_methods_intro' => 'Los pagos en línea se procesan de forma segura a través de nuestro proveedor de pagos Stripe. En el checkout será redirigido a la página de pago, donde podrá elegir su método de pago preferido.',
|
||||
'payment_method_applepay' => 'Apple Pay',
|
||||
'payment_method_applepay_copy' => 'Pague en pocos segundos con su dispositivo Apple, sin introducir los datos de la tarjeta; disponible en Safari y en iPhone y iPad.',
|
||||
'payment_method_googlepay' => 'Google Pay',
|
||||
'payment_method_googlepay_copy' => 'Pague de forma rápida y segura con los datos de pago de su cuenta de Google, p. ej. en Chrome o en su dispositivo Android.',
|
||||
'payment_method_klarna' => 'Klarna',
|
||||
'payment_method_klarna_copy' => 'Pague cómodamente a través de Klarna. Verá qué opciones de Klarna están disponibles para usted directamente en la página de pago.',
|
||||
'payment_method_amazonpay' => 'Amazon Pay',
|
||||
'payment_method_amazonpay_copy' => 'Pague con los datos de pago y dirección de su cuenta de Amazon, sin necesidad de un nuevo registro.',
|
||||
'payment_method_link' => 'Link (Stripe)',
|
||||
'payment_method_link_copy' => 'Con Link guarda sus datos de pago una sola vez y de forma segura en Stripe y después paga con un clic en todas las tiendas compatibles con Link.',
|
||||
'payment_method_bancontact' => 'Bancontact',
|
||||
'payment_method_bancontact_copy' => 'El método de pago más popular en Bélgica: se paga directamente a través de su cuenta bancaria belga.',
|
||||
'payment_method_eps' => 'EPS',
|
||||
'payment_method_eps_copy' => 'La transferencia en línea para clientes con cuenta bancaria austriaca: se paga directamente a través de su banca en línea.',
|
||||
'payment_method_more' => 'Otros métodos de pago',
|
||||
'payment_method_more_copy' => 'Según el país y el dispositivo, en la página de pago encontrará otros métodos de pago, p. ej. BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay o PAYCO.',
|
||||
];
|
||||
|
|
|
|||
63
resources/lang/es/withdrawal.php
Normal file
63
resources/lang/es/withdrawal.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
// Buttons / Navigation
|
||||
'button_label' => 'Revocar contrato',
|
||||
'nav_title' => 'Desistimiento',
|
||||
|
||||
// Identifikationsformular
|
||||
'page_title' => 'Revocar contrato',
|
||||
'intro' => 'Aquí puede revocar fácilmente su pedido realizado en línea. Primero, identifique su pedido.',
|
||||
'identify_hl' => 'Identificar pedido',
|
||||
'order_number' => 'Número de pedido',
|
||||
'order_number_placeholder' => 'p. ej. 12345',
|
||||
'order_number_help' => 'Encontrará el número de pedido en su confirmación de pedido.',
|
||||
'email' => 'Dirección de correo electrónico del pedido',
|
||||
'email_placeholder' => 'su@email.es',
|
||||
'find_order' => 'Buscar pedido',
|
||||
|
||||
// Bestellung gefunden / Auswahl
|
||||
'order_found_hl' => 'Su pedido',
|
||||
'order_date' => 'Fecha del pedido',
|
||||
'choose_scope_hl' => '¿Qué desea revocar?',
|
||||
'scope_full' => 'Desistimiento completo (todo el pedido)',
|
||||
'scope_partial' => 'Desistimiento parcial (artículos individuales)',
|
||||
'select_items_hl' => 'Seleccionar artículos',
|
||||
'product' => 'Artículo',
|
||||
'quantity' => 'Cantidad',
|
||||
'withdraw_quantity' => 'Cantidad a revocar',
|
||||
'reason' => 'Motivo (opcional)',
|
||||
'reason_placeholder' => 'No está obligado a indicar un motivo. Si lo desea, puede comunicárnoslo aquí.',
|
||||
'submit' => 'Enviar desistimiento de forma vinculante',
|
||||
'back' => 'Atrás',
|
||||
'legal_note' => 'Con el envío declara el desistimiento de su contrato frente a mivita care gmbh, Leinfeld 2, 87755 Kirchhaslach (Alemania). Recibirá de inmediato una confirmación de recepción por correo electrónico con fecha y hora.',
|
||||
|
||||
// Fehler
|
||||
'not_found' => 'No hemos podido encontrar ningún pedido que coincida con estos datos. Compruebe el número de pedido y la dirección de correo electrónico.',
|
||||
'no_items_selected' => 'Seleccione al menos un artículo para el desistimiento parcial.',
|
||||
|
||||
// Erfolg / Eingangsbestätigung
|
||||
'success_hl' => 'Hemos recibido su desistimiento',
|
||||
'success_intro' => 'Muchas gracias. Hemos recibido su desistimiento y lo tramitaremos lo antes posible.',
|
||||
'clock' => 'h',
|
||||
'success_received_at' => 'Recepción de su desistimiento',
|
||||
'success_reference' => 'Número de referencia',
|
||||
'success_email_sent' => 'Además, hemos enviado una confirmación de recepción a :email.',
|
||||
'success_full' => 'Ha revocado el pedido completo.',
|
||||
'success_partial' => 'Ha revocado artículos individuales de su pedido.',
|
||||
|
||||
// E-Mail
|
||||
'mail_subject' => 'Confirmación de recepción de su desistimiento',
|
||||
'mail_hello' => 'Hola',
|
||||
'mail_intro' => 'confirmamos la recepción de su desistimiento del siguiente pedido. Su desistimiento nos llegó en el momento indicado:',
|
||||
'mail_received_at' => 'Recibido el',
|
||||
'mail_order_number' => 'Número de pedido',
|
||||
'mail_reference' => 'Número de referencia',
|
||||
'mail_scope' => 'Alcance del desistimiento',
|
||||
'mail_items_hl' => 'Artículos revocados',
|
||||
'mail_outro' => 'Nos encargaremos de la tramitación y nos pondremos en contacto con usted si es necesario.',
|
||||
'mail_greetings' => 'Un cordial saludo',
|
||||
'mail_sender' => 'Su equipo de mivita.care',
|
||||
|
||||
];
|
||||
|
|
@ -129,6 +129,10 @@ return [
|
|||
'abo_subtotal' => 'Sous-total produits d\'abonnement',
|
||||
'abo_next_delivery_total' => 'Montant total de la prochaine livraison',
|
||||
'onetime_empty' => 'Aucun produit ponctuel ajouté pour le moment.',
|
||||
'onetime_product' => 'Produit ponctuel',
|
||||
'onetime_create_hl' => 'Ajouter des produits ponctuels à la première livraison',
|
||||
'onetime_create_info' => 'Ces produits sont expédiés uniquement avec la première livraison de l\'abonnement et payés avec la commande d\'abonnement. Ils ne font pas partie de l\'abonnement récurrent et ne seront pas facturés à nouveau lors des livraisons suivantes.',
|
||||
'onetime_badge_first_delivery' => 'Ponctuel (première livraison uniquement)',
|
||||
'onetime_badge' => 'Ponctuel',
|
||||
'combined_summary_hl' => 'Total de la prochaine livraison',
|
||||
'create_abo' => 'Créer un abonnement',
|
||||
|
|
@ -249,4 +253,47 @@ return [
|
|||
'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é.',
|
||||
|
||||
// Kreditkarte / Zahlart aktualisieren (Abo)
|
||||
'update_card_title' => 'Mettre à jour la carte de crédit',
|
||||
'card_change_history' => 'Historique des modifications',
|
||||
'current_card' => 'Carte actuelle',
|
||||
'card_update_intro' => 'Enregistrez une nouvelle carte de crédit pour cet abonnement. Une autorisation de vérification de 0,01 EUR peut être effectuée, sans débit. Le prochain prélèvement utilisera la nouvelle carte.',
|
||||
'card_security_notice' => 'Vos données de carte ne sont pas enregistrées ici. Elles sont transmises chiffrées en SSL directement à notre prestataire de paiement PAYONE, uniquement à des fins de vérification.',
|
||||
'card_holder_required' => 'Veuillez indiquer le nom du titulaire de la carte.',
|
||||
'card_number_invalid' => 'Veuillez saisir un numéro de carte de crédit valide.',
|
||||
'card_expiry_invalid' => 'Veuillez saisir une date d\'expiration valide.',
|
||||
'card_cvc_invalid' => 'Veuillez saisir un code de sécurité (CVC) valide.',
|
||||
'card_type_invalid' => 'Veuillez sélectionner un type de carte valide (Visa ou Mastercard).',
|
||||
'card_update_success' => 'Votre carte de crédit a été mise à jour. Le prochain prélèvement utilisera la nouvelle carte.',
|
||||
'card_update_failed' => 'La carte de crédit n\'a pas pu être mise à jour. Veuillez réessayer.',
|
||||
'card_update_not_allowed' => 'La carte de crédit ne peut pas être mise à jour pour cet abonnement.',
|
||||
'card_update_link_button' => 'Envoyer le lien de mise à jour au client',
|
||||
'card_update_link_sent' => 'Le lien de mise à jour a été envoyé au client.',
|
||||
'card_update_link_invalid' => 'Ce lien est invalide ou a expiré.',
|
||||
'card_update_link_not_allowed' => 'Aucun lien de mise à jour ne peut être envoyé pour cet abonnement.',
|
||||
'card_update_link_mail_subject' => 'Mettez à jour la carte de crédit de votre abonnement',
|
||||
'card_update_link_mail_intro' => 'Veuillez mettre à jour la carte de crédit de votre abonnement via le lien sécurisé ci-dessous. Vos données de carte sont transmises chiffrées en SSL uniquement à notre prestataire de paiement PAYONE.',
|
||||
'card_update_link_mail_button' => 'Mettre à jour la carte de crédit',
|
||||
'card_update_link_mail_hint' => 'Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :',
|
||||
'card_update_done_success_title' => 'Carte de crédit mise à jour',
|
||||
'card_update_done_success_copy' => 'Merci. Le prochain prélèvement utilisera votre nouvelle carte.',
|
||||
'card_update_done_failed_title' => 'Échec de la mise à jour',
|
||||
'card_update_done_failed_copy' => 'La carte de crédit n\'a pas pu être mise à jour. Demandez un nouveau lien si nécessaire.',
|
||||
'card_update_portal_hint' => 'Vous retrouverez également cet abonnement dans votre portail client. Vous pouvez vous y connecter et gérer vous-même votre moyen de paiement et votre abonnement à tout moment.',
|
||||
'card_update_to_portal' => 'Aller au portail client',
|
||||
'payment_method' => 'Moyen de paiement',
|
||||
'current_payment_method' => 'Moyen de paiement actuel',
|
||||
'update_payment_method' => 'Mettre à jour le moyen de paiement',
|
||||
'payment_method_change_title' => 'Changer de moyen de paiement',
|
||||
'payment_method_change_intro' => 'Choisissez un nouveau moyen de paiement pour cet abonnement sur la page de paiement sécurisée — par exemple si votre carte a expiré ou si vous souhaitez payer autrement à l\'avenir.',
|
||||
'payment_method_change_no_charge' => 'Rien ne sera débité — le nouveau moyen de paiement s\'applique à partir du prochain paiement de l\'abonnement.',
|
||||
'payment_method_change_button' => 'Choisir un nouveau moyen de paiement',
|
||||
'payment_method_change_success' => 'Votre moyen de paiement a été mis à jour. Le prochain paiement utilisera le nouveau moyen.',
|
||||
'payment_method_change_failed' => 'Le moyen de paiement n\'a pas pu être changé. Veuillez réessayer.',
|
||||
'payment_method_change_done_title' => 'Moyen de paiement mis à jour',
|
||||
'payment_method_change_done_failed_copy' => 'Le moyen de paiement n\'a pas pu être changé. Demandez un nouveau lien si nécessaire.',
|
||||
'payment_method_change_link_mail_subject' => 'Mettez à jour le moyen de paiement de votre abonnement',
|
||||
'payment_method_change_link_mail_intro' => 'Veuillez mettre à jour le moyen de paiement de votre abonnement via le lien sécurisé suivant. Vous choisissez votre nouveau moyen de paiement directement sur la page sécurisée de notre prestataire de paiement Stripe — rien ne sera débité.',
|
||||
'payment_method_change_link_mail_button' => 'Mettre à jour le moyen de paiement',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -89,4 +89,8 @@ return [
|
|||
'team_customers' => 'Clients de l’équipe',
|
||||
'payment_monitor' => 'Moniteur de paiement',
|
||||
'payment_monitor_management' => 'Moniteur de paiement direction',
|
||||
|
||||
'my_statistics' => 'Ma statistique',
|
||||
'team_statistics' => 'Statistiques de l\'équipe',
|
||||
'withdrawals' => 'Rétractations',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -189,4 +189,20 @@ return [
|
|||
'packstation_alert_location' => 'Emplacement de la Packstation',
|
||||
'packstation_alert_not_home' => 'pas votre adresse personnelle !',
|
||||
'packstation_alert_footer' => 'Le NUMÉRO de Packstation comporte 3 chiffres (100-999, indiqué sur le panneau jaune). Le numéro postal DHL comporte 6 à 10 chiffres et doit être saisi dans le champ séparé ci-dessus.',
|
||||
|
||||
'prepayment_payone' => 'Paiement anticipé (PAYONE)',
|
||||
'prepayment_no_clearing_data' => 'Les coordonnées bancaires ne sont pas encore disponibles — le paiement n\'a pas encore été enregistré avec succès auprès du prestataire de paiement.',
|
||||
'checkout_maintenance_title' => 'Difficultés techniques',
|
||||
'checkout_maintenance' => 'Les commandes ne sont actuellement pas possibles en raison de difficultés techniques. Nous serons bientôt de retour — veuillez réessayer plus tard.',
|
||||
'abo_payments_frozen_title' => 'Paiements d\'abonnement temporairement indisponibles',
|
||||
'abo_payments_frozen' => 'Les paiements d\'abonnement ne sont actuellement pas possibles. Nous travaillons d\'arrache-pied à une solution technique — veuillez réessayer plus tard. Vos abonnements et commandes ne seront pas perdus.',
|
||||
'stripe_online' => 'Paiement en ligne',
|
||||
'stripe_online_short' => 'Paiement en ligne (Stripe)',
|
||||
'stripe_online_copy' => 'Vous serez redirigé vers la page de paiement sécurisée où vous choisirez votre moyen de paiement.',
|
||||
'stripe_online_methods' => 'Carte de crédit, PayPal, Apple Pay, Google Pay, Klarna, Amazon Pay et bien plus',
|
||||
'stripe_order_label' => 'Commande :reference',
|
||||
'stripe_unavailable' => 'Le paiement en ligne est actuellement indisponible. Veuillez réessayer dans un instant.',
|
||||
'stripe_abo_hint' => 'Votre moyen de paiement est enregistré en toute sécurité chez notre prestataire de paiement Stripe et utilisé pour les prochains prélèvements de l\'abonnement.',
|
||||
'stripe_abo_payment_method_missing' => 'Aucun moyen de paiement valide n\'est enregistré pour cet abonnement. Veuillez mettre à jour votre moyen de paiement.',
|
||||
'stripe_abo_authentication_required' => 'Votre banque exige une confirmation du paiement. Veuillez finaliser le paiement depuis votre page d\'abonnement.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -22,10 +22,6 @@ return [
|
|||
'payment_methods' => 'Modes de paiement',
|
||||
'payment_method_paypal' => 'Paiement avec PayPal',
|
||||
'payment_method_paypal_copy' => 'Après vérification réussie, vous serez redirigé vers la page de paiement sécurisée de PayPal afin de poursuivre le paiement. Veuillez ne pas fermer le navigateur après le paiement réussi avant d’être redirigé vers la boutique.',
|
||||
'payment_method_direct' => 'Virement immédiat',
|
||||
'payment_method_direct_copy' => 'Après vérification réussie, vous serez redirigé vers la page de paiement sécurisée de PAYONE pour les virements SOFORT afin de poursuivre le paiement. Veuillez ne pas fermer le navigateur après le paiement réussi avant d’être redirigé vers la boutique.',
|
||||
'payment_method_sepa' => 'Prélèvement SEPA',
|
||||
'payment_method_sepa_copy' => 'Votre compte sera débité pour cette commande unique après l’envoi de la commande. Le prélèvement SEPA est traité par notre prestataire de paiement PAYONE.',
|
||||
'payment_method_creditcard' => 'Carte de crédit',
|
||||
'payment_method_creditcard_copy' => 'Le montant sera débité de votre carte de crédit dès l’envoi de la commande. Le paiement par carte de crédit est traité par notre prestataire de paiement PAYONE.',
|
||||
'payment_method_prepayment' => 'Paiement anticipé',
|
||||
|
|
@ -42,4 +38,22 @@ return [
|
|||
'to_checkout' => 'vers la caisse',
|
||||
'all_price_plus_VAT_info' => 'Tous les prix incluent la TVA légale :link, sauf indication contraire',
|
||||
'' => '',
|
||||
|
||||
'payment_methods_intro' => 'Les paiements en ligne sont traités en toute sécurité via notre prestataire de paiement Stripe. Lors du checkout, vous êtes redirigé vers la page de paiement où vous choisissez votre moyen de paiement préféré.',
|
||||
'payment_method_applepay' => 'Apple Pay',
|
||||
'payment_method_applepay_copy' => 'Payez en quelques secondes avec votre appareil Apple, sans saisir vos données de carte — disponible dans Safari ainsi que sur iPhone et iPad.',
|
||||
'payment_method_googlepay' => 'Google Pay',
|
||||
'payment_method_googlepay_copy' => 'Payez rapidement et en toute sécurité avec les données de paiement de votre compte Google, par ex. dans Chrome ou sur votre appareil Android.',
|
||||
'payment_method_klarna' => 'Klarna',
|
||||
'payment_method_klarna_copy' => 'Payez confortablement via Klarna. Vous verrez directement sur la page de paiement quelles options Klarna sont disponibles pour vous.',
|
||||
'payment_method_amazonpay' => 'Amazon Pay',
|
||||
'payment_method_amazonpay_copy' => 'Payez avec les données de paiement et d\'adresse de votre compte Amazon — sans nouvelle inscription.',
|
||||
'payment_method_link' => 'Link (Stripe)',
|
||||
'payment_method_link_copy' => 'Avec Link, vous enregistrez vos données de paiement une seule fois et en toute sécurité chez Stripe, puis vous payez en un clic dans toutes les boutiques compatibles Link.',
|
||||
'payment_method_bancontact' => 'Bancontact',
|
||||
'payment_method_bancontact_copy' => 'Le moyen de paiement le plus populaire en Belgique — vous payez directement via votre compte bancaire belge.',
|
||||
'payment_method_eps' => 'EPS',
|
||||
'payment_method_eps_copy' => 'Le virement en ligne pour les clients disposant d\'un compte bancaire autrichien — vous payez directement via votre banque en ligne.',
|
||||
'payment_method_more' => 'Autres moyens de paiement',
|
||||
'payment_method_more_copy' => 'Selon le pays et l\'appareil, d\'autres moyens de paiement sont disponibles sur la page de paiement — par ex. BLIK, MB WAY, Pix, Satispay, Samsung Pay, Kakao Pay, Naver Pay ou PAYCO.',
|
||||
];
|
||||
|
|
|
|||
63
resources/lang/fr/withdrawal.php
Normal file
63
resources/lang/fr/withdrawal.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
// Buttons / Navigation
|
||||
'button_label' => 'Révoquer le contrat',
|
||||
'nav_title' => 'Rétractation',
|
||||
|
||||
// Identifikationsformular
|
||||
'page_title' => 'Révoquer le contrat',
|
||||
'intro' => 'Vous pouvez ici vous rétracter facilement de votre commande passée en ligne. Veuillez d\'abord identifier votre commande.',
|
||||
'identify_hl' => 'Identifier la commande',
|
||||
'order_number' => 'Numéro de commande',
|
||||
'order_number_placeholder' => 'p. ex. 12345',
|
||||
'order_number_help' => 'Vous trouverez le numéro de commande dans votre confirmation de commande.',
|
||||
'email' => 'Adresse e-mail de la commande',
|
||||
'email_placeholder' => 'votre@email.fr',
|
||||
'find_order' => 'Rechercher la commande',
|
||||
|
||||
// Bestellung gefunden / Auswahl
|
||||
'order_found_hl' => 'Votre commande',
|
||||
'order_date' => 'Date de la commande',
|
||||
'choose_scope_hl' => 'De quoi souhaitez-vous vous rétracter ?',
|
||||
'scope_full' => 'Rétractation complète (toute la commande)',
|
||||
'scope_partial' => 'Rétractation partielle (articles individuels)',
|
||||
'select_items_hl' => 'Sélectionner les articles',
|
||||
'product' => 'Article',
|
||||
'quantity' => 'Quantité',
|
||||
'withdraw_quantity' => 'Quantité à rétracter',
|
||||
'reason' => 'Motif (facultatif)',
|
||||
'reason_placeholder' => 'Vous n\'êtes pas obligé d\'indiquer un motif. Si vous le souhaitez, vous pouvez nous le communiquer ici.',
|
||||
'submit' => 'Envoyer la rétractation de manière ferme',
|
||||
'back' => 'Retour',
|
||||
'legal_note' => 'En envoyant ce formulaire, vous déclarez la rétractation de votre contrat auprès de mivita care gmbh, Leinfeld 2, 87755 Kirchhaslach (Allemagne). Vous recevrez immédiatement une confirmation de réception par e-mail avec la date et l\'heure.',
|
||||
|
||||
// Fehler
|
||||
'not_found' => 'Nous n\'avons trouvé aucune commande correspondant à ces informations. Veuillez vérifier le numéro de commande et l\'adresse e-mail.',
|
||||
'no_items_selected' => 'Veuillez sélectionner au moins un article pour la rétractation partielle.',
|
||||
|
||||
// Erfolg / Eingangsbestätigung
|
||||
'success_hl' => 'Votre rétractation nous est bien parvenue',
|
||||
'success_intro' => 'Merci beaucoup. Nous avons bien reçu votre rétractation et la traiterons dans les meilleurs délais.',
|
||||
'clock' => 'h',
|
||||
'success_received_at' => 'Réception de votre rétractation',
|
||||
'success_reference' => 'Numéro de dossier',
|
||||
'success_email_sent' => 'Nous avons également envoyé une confirmation de réception à :email.',
|
||||
'success_full' => 'Vous vous êtes rétracté de la commande complète.',
|
||||
'success_partial' => 'Vous vous êtes rétracté d\'articles individuels de votre commande.',
|
||||
|
||||
// E-Mail
|
||||
'mail_subject' => 'Confirmation de réception de votre rétractation',
|
||||
'mail_hello' => 'Bonjour',
|
||||
'mail_intro' => 'nous confirmons la réception de votre rétractation concernant la commande suivante. Votre rétractation nous est parvenue au moment indiqué :',
|
||||
'mail_received_at' => 'Reçue le',
|
||||
'mail_order_number' => 'Numéro de commande',
|
||||
'mail_reference' => 'Numéro de dossier',
|
||||
'mail_scope' => 'Étendue de la rétractation',
|
||||
'mail_items_hl' => 'Articles rétractés',
|
||||
'mail_outro' => 'Nous nous occupons de la suite du traitement et vous recontacterons si nécessaire.',
|
||||
'mail_greetings' => 'Meilleures salutations',
|
||||
'mail_sender' => 'Votre équipe mivita.care',
|
||||
|
||||
];
|
||||
|
|
@ -77,7 +77,11 @@
|
|||
<div class="product-title">
|
||||
{{ $row->name }}
|
||||
|
||||
{!! get_abo_type_badge_by_product($product) !!}
|
||||
@if ($row->options->abo_addon)
|
||||
<span class="badge badge-pill badge-warning"><i class="fa fa-bolt"></i> {{ __('abo.onetime_badge_first_delivery') }}</span>
|
||||
@else
|
||||
{!! get_abo_type_badge_by_product($product) !!}
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<div class="product-details">
|
||||
|
|
@ -121,6 +125,13 @@
|
|||
</div>
|
||||
@endforeach
|
||||
|
||||
@php($hasOneTimeRows = Yard::instance('subscription')->getContentByOrder()->contains(function ($item) { return (bool) ($item->options->abo_addon ?? false); }))
|
||||
@if ($hasOneTimeRows)
|
||||
<div class="alert alert-info mt-3">
|
||||
<i class="fa fa-bolt text-warning"></i> {{ __('abo.onetime_create_info') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="cart-summary mt-4">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@
|
|||
'formRoute' => 'portal.my_subscriptions.create',
|
||||
'formStep' => 4
|
||||
])
|
||||
|
||||
@if(count($onetime_products ?? []) > 0)
|
||||
<hr class="my-4">
|
||||
<h5><i class="fa fa-bolt text-warning"></i> {{ __('abo.onetime_create_hl') }}</h5>
|
||||
<div class="alert alert-info">
|
||||
{{ __('abo.onetime_create_info') }}
|
||||
</div>
|
||||
@include('portal.abo.componenten.onetime-product-table', [
|
||||
'products' => $onetime_products,
|
||||
])
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
@props([
|
||||
'products' => [],
|
||||
])
|
||||
|
||||
{{-- Einmalprodukte (Normalsortiment) zur ersten Abo-Lieferung: eigene Cart-Zeilen mit
|
||||
Option abo_addon (eigene rowId), daher eigene Warenkorb-Suche statt getCartItemByProduct. --}}
|
||||
@if(count($products) > 0)
|
||||
<div class="table-responsive">
|
||||
<table class="table table- m-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('abo.onetime_product') }}</th>
|
||||
<th> </th>
|
||||
<th class="text-right">{{ __('order.unit_price') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($products as $product)
|
||||
|
||||
@php
|
||||
$cartItem = Yard::instance('subscription')->search(function ($item) use ($product) {
|
||||
return (int) $item->id === (int) $product->id && ($item->options->abo_addon ?? false);
|
||||
})->first();
|
||||
$qty = isset($cartItem->qty) ? $cartItem->qty : 0;
|
||||
$rowId = isset($cartItem->rowId) ? $cartItem->rowId : '';
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="text-center align-middle px-0" data-label="{{ __('abo.onetime_product') }}">
|
||||
<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 remove-product-qty"
|
||||
data-row-id="{{ $rowId }}"
|
||||
data-product-id="{{ $product->id }}">-</button>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control text-center input-extra table-input-event-onchange"
|
||||
name="onetime_product_qty[{{ $product->id }}]"
|
||||
data-row-id="{{ $rowId }}"
|
||||
data-product-id="{{ $product->id }}"
|
||||
value="{{ $qty }}">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-secondary icon-btn md-btn-extra add-product-qty"
|
||||
data-row-id="{{ $rowId }}"
|
||||
data-product-id="{{ $product->id }}">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4" data-label="">
|
||||
<div class="media align-items-center product-inactive" id="onetime-product-{{ $product->id }}">
|
||||
@if(count($product->images))
|
||||
<img src="{{ route('product_image', [$product->images->first()->slug]) }}"
|
||||
class="d-block ui-w-100 ui-bordered mr-4"
|
||||
alt="{{ $product->getLang('name') }}">
|
||||
@endif
|
||||
<div class="media-body">
|
||||
<h5 class="d-block text-dark">
|
||||
{{$product->getLang('name')}}
|
||||
<br><span class="badge badge-pill badge-warning"><i class="fa fa-bolt"></i> {{ __('abo.onetime_badge') }}</span>
|
||||
</h5>
|
||||
{!! $product->getLang('copy') !!}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right font-weight-semibold align-middle p-4" data-label="{{ __('order.unit_price') }}">
|
||||
<span class="no-line-break">
|
||||
{{ $product->getFormattedPriceWith(\App\Services\UserService::getTaxFree(), false, \App\Services\UserService::$user_country) }} €
|
||||
</span>
|
||||
@if(\App\Services\UserService::$user_country && \App\Services\UserService::$user_country->currency)
|
||||
<span class="no-line-break">
|
||||
{!! $product->getFormattedPriceCurrencyWith(\App\Services\UserService::getTaxFree(), false, \App\Services\UserService::$user_country) !!}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
73
resources/views/user/order/_onetime_list.blade.php
Normal file
73
resources/views/user/order/_onetime_list.blade.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{{-- Einmalprodukte bei der Abo-Anlage: eigene Produktliste (Normalsortiment), Zeilen landen
|
||||
mit Cart-Option abo_addon=true im selben Warenkorb (nur erste Lieferung, kein Abo-Bestandteil). --}}
|
||||
<div class="card mt-4">
|
||||
<div class="card-body pb-2">
|
||||
<h4 class="mb-1"><i class="fa fa-bolt text-warning"></i> {{ __('abo.onetime_create_hl') }}</h4>
|
||||
<p class="small text-muted mb-0">{{ __('abo.onetime_create_info') }}</p>
|
||||
</div>
|
||||
<div class="card-datatable table-responsive">
|
||||
<table class="table table-striped table-bordered" id="datatables-onetime-list" data-url="{{ route('user_order_my_perform_request') }}">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ __('tables.image') }}</th>
|
||||
<th>{{ __('tables.product') }}</th>
|
||||
@if ($template === 'me')
|
||||
<th>{{ __('tables.my_price_net') }}</th>
|
||||
<th>{{ __('tables.my_price_gross') }}</th>
|
||||
@else
|
||||
<th>{{ __('tables.vk_price') }} {{ __('tables.net') }}</th>
|
||||
<th>{{ __('tables.vk_price') }} {{ __('tables.gross') }}</th>
|
||||
@endif
|
||||
<th>{{ __('tables.points') }}</th>
|
||||
<th><span class="no-line-break">{{ __('tables.contents') }} (ml)</span></th>
|
||||
<th>{{ __('tables.article_no') }}</th>
|
||||
<th>#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('#datatables-onetime-list').DataTable({
|
||||
"processing": true,
|
||||
"serverSide": true,
|
||||
ajax: {
|
||||
url: '{!! route('user_order_my_datatable') !!}',
|
||||
data: function (d) {
|
||||
d.shipping_is_for = $('input[name=shipping_is_for]').val();
|
||||
d.is_abo = $('input[name=is_abo]').val();
|
||||
d.abo_addon = 1;
|
||||
}
|
||||
},
|
||||
"order": [[6, "asc"]],
|
||||
"columns": [
|
||||
{ data: 'picture', name: 'picture', searchable: false, width: 35 },
|
||||
{ data: 'product', name: 'product' },
|
||||
@if ($template === 'me')
|
||||
{ data: 'price_net', name: 'price_net', searchable: false, orderable: false },
|
||||
{ data: 'price_gross', name: 'price_gross', searchable: false, orderable: false },
|
||||
@else
|
||||
{ data: 'customer_price_net', name: 'customer_price_net', searchable: false, orderable: false },
|
||||
{ data: 'customer_price_gross', name: 'customer_price_gross', searchable: false, orderable: false },
|
||||
@endif
|
||||
{ data: 'points', name: 'points', searchable: false },
|
||||
{ data: 'contents_total', name: 'contents_total', searchable: false },
|
||||
{ data: 'number', name: 'number' },
|
||||
{ data: 'action', name: 'action', searchable: false, orderable: false },
|
||||
],
|
||||
"bLengthChange": false,
|
||||
"iDisplayLength": 1000,
|
||||
"paging": false,
|
||||
"language": {
|
||||
"url": "/js/datatables-{{ \App::getLocale() }}.json"
|
||||
},
|
||||
drawCallback: function (settings) {
|
||||
IqShoppingCart.reInit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -23,6 +23,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if($is_abo)
|
||||
@include('user.order._onetime_list')
|
||||
@endif
|
||||
|
||||
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
|
||||
<input type="hidden" name="shipping_is_for" value="{{$for}}">
|
||||
<input type="hidden" name="is_abo" value="{{$is_abo}}">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if($is_abo)
|
||||
@include('user.order._onetime_list')
|
||||
@endif
|
||||
|
||||
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
|
||||
<input type="hidden" name="shipping_is_for" value="{{$for}}">
|
||||
<input type="hidden" name="is_abo" value="{{$is_abo}}">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if($is_abo)
|
||||
@include('user.order._onetime_list')
|
||||
@endif
|
||||
|
||||
{!! Form::open(['action' => route('user_order_my_payment', [$for, $delivery_id]), 'class' => 'form-horizontal']) !!}
|
||||
<input type="hidden" name="shipping_is_for" value="{{$for}}">
|
||||
<input type="hidden" name="is_abo" value="{{$is_abo}}">
|
||||
|
|
|
|||
|
|
@ -165,7 +165,9 @@
|
|||
<div class="col-12 col-sm-6 col-md-7 description">
|
||||
<div class="media-body">
|
||||
<div class="d-block text-body" style="font-size: 15px; font-weight: 500;">
|
||||
{{ $row->name }} @if (isset($is_abo) && $is_abo)
|
||||
{{ $row->name }} @if ($row->options->abo_addon)
|
||||
<span class="badge badge-warning">{{ __('abo.onetime_badge_first_delivery') }}</span>
|
||||
@elseif (isset($is_abo) && $is_abo)
|
||||
{!! get_abo_type_badge_by_product($product) !!}
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -184,7 +186,8 @@
|
|||
@if (!$row->options->comp)
|
||||
<a href="#"
|
||||
class="auto-delete-product remove_item_form_cart product-tooltip"
|
||||
data-row-id="{{ $row->rowId }}" data-product-id="{{ $product->id }}"><i
|
||||
data-row-id="{{ $row->rowId }}" data-product-id="{{ $product->id }}"
|
||||
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}"><i
|
||||
class="fa fa-times"></i> {{ __('order.article_remove') }}</a>
|
||||
@else
|
||||
@if (Yard::instance('shopping')->getNumComp() > 1)
|
||||
|
|
@ -218,18 +221,21 @@
|
|||
<button type="button"
|
||||
class="btn btn-secondary icon-btn md-btn-extra cart-remove-event"
|
||||
data-row-id="{{ $row->rowId }}"
|
||||
data-product-id="{{ $product->id }}">-</button>
|
||||
data-product-id="{{ $product->id }}"
|
||||
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}">-</button>
|
||||
</span>
|
||||
<input type="text"
|
||||
class="form-control text-center input-extra cart-input-event-onchange"
|
||||
data-row-id="{{ $row->rowId }}"
|
||||
data-product-id="{{ $product->id }}" value="{{ $row->qty }}"
|
||||
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}"
|
||||
name="quantity[{{ $row->rowId }}]">
|
||||
<span class="input-group-append">
|
||||
<button type="button"
|
||||
class="btn btn-secondary icon-btn md-btn-extra cart-add-event"
|
||||
data-row-id="{{ $row->rowId }}"
|
||||
data-product-id="{{ $product->id }}">+</button>
|
||||
data-product-id="{{ $product->id }}"
|
||||
data-abo-addon="{{ $row->options->abo_addon ? 1 : 0 }}">+</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
134
tests/Feature/AboCreateOneTimeCartTest.php
Normal file
134
tests/Feature/AboCreateOneTimeCartTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Einmalprodukte in der Sales-Center-Abo-Bestellstrecke (Anlage, Variante A):
|
||||
* updateCart mit abo_addon=1 legt eine getrennte Cart-Zeile (Option abo_addon) an,
|
||||
* Sortiment-Prüfung läuft gegen das Einmal-Sortiment (show_on '3').
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\User\OrderController;
|
||||
use App\Models\Country;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShippingPrice;
|
||||
use App\Services\ProductOrderContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function makeOneTimeCartEnv(): ShippingCountry
|
||||
{
|
||||
$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]);
|
||||
ShippingPrice::create([
|
||||
'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 0, 'num_comp' => 0,
|
||||
'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 100000,
|
||||
]);
|
||||
|
||||
return ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft OrderController::performRequest direkt auf (Domain-Routing hat einen Catch-All,
|
||||
* daher kein HTTP-Roundtrip). Liefert das dekodierte JSON-Payload zurück.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function postCartUpdate(array $payload): array
|
||||
{
|
||||
$request = request();
|
||||
$request->replace(array_merge([
|
||||
'action' => 'updateCart',
|
||||
'shipping_is_for' => 'abo-ot-customer',
|
||||
'is_abo' => '1',
|
||||
], $payload));
|
||||
$request->headers->set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
$response = app(OrderController::class)->performRequest();
|
||||
|
||||
return $response->getData(true);
|
||||
}
|
||||
|
||||
function findYardRows(int $productId, bool $addon): Collection
|
||||
{
|
||||
return Yard::instance('shopping')->search(function ($item) use ($productId, $addon) {
|
||||
return (int) $item->id === $productId && (bool) ($item->options->abo_addon ?? false) === $addon;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->shippingCountry = makeOneTimeCartEnv();
|
||||
$yard = Yard::instance('shopping');
|
||||
$yard->destroy();
|
||||
$yard->setShippingCountryWithPrice($this->shippingCountry->id, 'abo-ot-customer');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Yard::instance('shopping')->destroy();
|
||||
});
|
||||
|
||||
it('liefert das Einmal-Sortiment (show_on 3) für Abo-Zusatzartikel', function () {
|
||||
expect(ProductOrderContext::oneTimeShowOnIds())->toBe(['3']);
|
||||
});
|
||||
|
||||
it('legt mit abo_addon=1 eine getrennte Einmal-Cart-Zeile an', function () {
|
||||
$product = makeProduct(['3'], 25.50, 19);
|
||||
|
||||
$result = postCartUpdate(['product_id' => $product->id, 'qty' => 2, 'abo_addon' => 1]);
|
||||
expect($result['response'])->toBeTrue();
|
||||
|
||||
$rows = findYardRows($product->id, true);
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows->first()->qty)->toBe(2)
|
||||
->and((bool) $rows->first()->options->abo_addon)->toBeTrue();
|
||||
});
|
||||
|
||||
it('hält Abo-Zeile und Einmal-Zeile desselben Produkts getrennt', function () {
|
||||
$product = makeProduct(['3', '12'], 90, 19);
|
||||
|
||||
postCartUpdate(['product_id' => $product->id, 'qty' => 1]);
|
||||
postCartUpdate(['product_id' => $product->id, 'qty' => 3, 'abo_addon' => 1]);
|
||||
|
||||
$aboRows = findYardRows($product->id, false);
|
||||
$addonRows = findYardRows($product->id, true);
|
||||
|
||||
expect($aboRows)->toHaveCount(1)
|
||||
->and((int) $aboRows->first()->qty)->toBe(1)
|
||||
->and($addonRows)->toHaveCount(1)
|
||||
->and((int) $addonRows->first()->qty)->toBe(3)
|
||||
->and($aboRows->first()->rowId)->not->toBe($addonRows->first()->rowId);
|
||||
});
|
||||
|
||||
it('lehnt ein Normalsortiment-Produkt ohne abo_addon-Flag im Abo-Cart weiter ab', function () {
|
||||
$product = makeProduct(['3'], 25.50, 19);
|
||||
|
||||
$result = postCartUpdate(['product_id' => $product->id, 'qty' => 1]);
|
||||
|
||||
expect($result['message'] ?? null)->toBe(__('msg.cart_product_not_allowed_for_order_type'))
|
||||
->and(Yard::instance('shopping')->content())->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('lehnt ein reines Abo-Produkt als Einmal-Artikel ab', function () {
|
||||
$product = makeProduct(['12'], 90, 19);
|
||||
|
||||
$result = postCartUpdate(['product_id' => $product->id, 'qty' => 1, 'abo_addon' => 1]);
|
||||
|
||||
expect($result['message'] ?? null)->toBe(__('msg.cart_product_not_allowed_for_order_type'))
|
||||
->and(Yard::instance('shopping')->content())->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('entfernt mit qty=0 nur die Einmal-Zeile, die Abo-Zeile bleibt', function () {
|
||||
$product = makeProduct(['3', '12'], 90, 19);
|
||||
|
||||
postCartUpdate(['product_id' => $product->id, 'qty' => 1]);
|
||||
postCartUpdate(['product_id' => $product->id, 'qty' => 2, 'abo_addon' => 1]);
|
||||
postCartUpdate(['product_id' => $product->id, 'qty' => 0, 'abo_addon' => 1]);
|
||||
|
||||
expect(findYardRows($product->id, true))->toHaveCount(0)
|
||||
->and(findYardRows($product->id, false))->toHaveCount(1);
|
||||
});
|
||||
274
tests/Feature/AboCreateOneTimeItemsTest.php
Normal file
274
tests/Feature/AboCreateOneTimeItemsTest.php
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Einmalprodukte bei der Abo-Anlage (Variante A, Plan dev/2026-07-21-abo-einmalprodukte-anlage):
|
||||
* Positionen mit Cart-Option `abo_addon` werden beim Checkout als `is_abo_addon` persistiert,
|
||||
* fließen NICHT in die dauerhaften Abo-Positionen (user_abo_items) und nicht in user_abos.amount.
|
||||
*/
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShippingPrice;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingOrderItem;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboItem;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\PaymentHelper;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* @return array{user: User, shoppingUser: ShoppingUser, shippingCountry: ShippingCountry, userShop: UserShop}
|
||||
*/
|
||||
function aboCreateOneTimeFixture(): 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]);
|
||||
ShippingPrice::create([
|
||||
'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 0, 'num_comp' => 0,
|
||||
'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 100000,
|
||||
]);
|
||||
$shippingCountry = ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
|
||||
|
||||
$consultant = User::forceCreate([
|
||||
'email' => 'consultant-'.uniqid('', true).'@example.com',
|
||||
'password' => bcrypt('secret'),
|
||||
'lang' => 'de',
|
||||
]);
|
||||
|
||||
$userShop = UserShop::create([
|
||||
'user_id' => $consultant->id,
|
||||
'name' => 'TS'.substr(uniqid('', true), 0, 8),
|
||||
'slug' => 'ts-'.uniqid(),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$shoppingUser = ShoppingUser::create([
|
||||
'auth_user_id' => $consultant->id,
|
||||
'member_id' => $consultant->id,
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
'billing_email' => 'cust-'.uniqid('', true).'@example.com',
|
||||
'is_for' => 'ot',
|
||||
'is_from' => 'shopping',
|
||||
]);
|
||||
|
||||
return [
|
||||
'user' => $consultant,
|
||||
'shoppingUser' => $shoppingUser,
|
||||
'shippingCountry' => $shippingCountry,
|
||||
'userShop' => $userShop,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abo-Erstbestellung mit einem regulären Abo-Artikel (90,00 €) und einem
|
||||
* Einmal-Artikel (25,50 € x 2, is_abo_addon) plus bezahlter ShoppingPayment (100,00 €).
|
||||
*
|
||||
* @return array{order: ShoppingOrder, payment: ShoppingPayment, aboProductId: int, addonProductId: int}
|
||||
*/
|
||||
function aboCreateOrderWithAddon(array $fixture): array
|
||||
{
|
||||
$aboProduct = makeProduct(['12'], 90, 19);
|
||||
$addonProduct = makeProduct(['3'], 25.50, 19);
|
||||
|
||||
$order = ShoppingOrder::create([
|
||||
'shopping_user_id' => $fixture['shoppingUser']->id,
|
||||
'auth_user_id' => $fixture['user']->id,
|
||||
'member_id' => $fixture['user']->id,
|
||||
'country_id' => $fixture['shippingCountry']->id,
|
||||
'user_shop_id' => $fixture['userShop']->id,
|
||||
'payment_for' => 6,
|
||||
'points' => 10,
|
||||
'is_abo' => true,
|
||||
'abo_interval' => 15,
|
||||
'paid' => false,
|
||||
'txaction' => 'prev',
|
||||
'mode' => 'test',
|
||||
'total' => 141,
|
||||
'subtotal' => 118.49,
|
||||
'total_shipping' => 145.90,
|
||||
]);
|
||||
|
||||
ShoppingOrderItem::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'row_id' => 'row-abo',
|
||||
'product_id' => $aboProduct->id,
|
||||
'comp' => 0,
|
||||
'qty' => 1,
|
||||
'price' => 90,
|
||||
'price_net' => 75.63,
|
||||
'tax_rate' => 19,
|
||||
'tax' => 14.37,
|
||||
'slug' => $aboProduct->slug,
|
||||
'is_abo_addon' => false,
|
||||
]);
|
||||
ShoppingOrderItem::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'row_id' => 'row-addon',
|
||||
'product_id' => $addonProduct->id,
|
||||
'comp' => 0,
|
||||
'qty' => 2,
|
||||
'price' => 25.50,
|
||||
'price_net' => 21.43,
|
||||
'tax_rate' => 19,
|
||||
'tax' => 4.07,
|
||||
'slug' => $addonProduct->slug,
|
||||
'is_abo_addon' => true,
|
||||
]);
|
||||
|
||||
$payment = ShoppingPayment::create([
|
||||
'shopping_order_id' => $order->id,
|
||||
'reference' => 'ref-'.uniqid(),
|
||||
'amount' => 10000,
|
||||
'currency' => 'EUR',
|
||||
'clearingtype' => 'cc',
|
||||
'abo_interval' => 15,
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
PaymentTransaction::create([
|
||||
'shopping_payment_id' => $payment->id,
|
||||
'request' => 'transaction',
|
||||
'txid' => 123456,
|
||||
'userid' => 987654,
|
||||
'status' => 'PAYONE',
|
||||
'txaction' => 'paid',
|
||||
'mode' => 'test',
|
||||
]);
|
||||
|
||||
return [
|
||||
'order' => $order,
|
||||
'payment' => $payment,
|
||||
'aboProductId' => $aboProduct->id,
|
||||
'addonProductId' => $addonProduct->id,
|
||||
];
|
||||
}
|
||||
|
||||
it('persistiert die Cart-Option abo_addon als is_abo_addon auf der Checkout-Bestellung', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
|
||||
$aboProduct = makeProduct(['12'], 90, 19);
|
||||
$addonProduct = makeProduct(['3'], 25.50, 19);
|
||||
|
||||
$yard = Yard::instance('shopping');
|
||||
$yard->destroy();
|
||||
$yard->setShippingCountryWithPrice($fixture['shippingCountry']->id);
|
||||
|
||||
$yard->add($aboProduct->id, 'Abo-Produkt', 1, 90.0, false, false, [
|
||||
'slug' => $aboProduct->slug, 'weight' => 500, 'points' => 10,
|
||||
'no_commission' => false, 'no_free_shipping' => false, 'show_on' => ['12'],
|
||||
]);
|
||||
$yard->add($addonProduct->id, 'Einmal-Produkt', 2, 25.50, false, false, [
|
||||
'slug' => $addonProduct->slug, 'weight' => 200, 'points' => 4,
|
||||
'no_commission' => false, 'no_free_shipping' => false, 'show_on' => ['3'],
|
||||
'abo_addon' => true,
|
||||
]);
|
||||
|
||||
$order = (new PaymentHelper)->makeShoppingOrder($fixture['user'], $fixture['shoppingUser']);
|
||||
|
||||
$aboItem = $order->shopping_order_items()->where('product_id', $aboProduct->id)->first();
|
||||
$addonItem = $order->shopping_order_items()->where('product_id', $addonProduct->id)->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);
|
||||
|
||||
$yard->destroy();
|
||||
});
|
||||
|
||||
it('übernimmt Einmal-Artikel bei der Abo-Anlage NICHT in die dauerhaften Abo-Positionen', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
$orderData = aboCreateOrderWithAddon($fixture);
|
||||
|
||||
AboHelper::createNewAbo($orderData['payment']);
|
||||
|
||||
$userAbo = UserAbo::first();
|
||||
expect($userAbo)->not->toBeNull();
|
||||
|
||||
$items = UserAboItem::where('user_abo_id', $userAbo->id)->get();
|
||||
expect($items)->toHaveCount(1)
|
||||
->and((int) $items->first()->product_id)->toBe($orderData['aboProductId']);
|
||||
});
|
||||
|
||||
it('setzt user_abos.amount bei der Anlage ohne den Einmal-Artikel-Anteil', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
$orderData = aboCreateOrderWithAddon($fixture);
|
||||
|
||||
AboHelper::createNewAbo($orderData['payment']);
|
||||
|
||||
// Zahlung 100,00 € (10000 Cent) minus Addon-Brutto 2 x 25,50 € = 51,00 € -> 4900 Cent
|
||||
expect((int) UserAbo::first()->amount)->toBe(4900);
|
||||
});
|
||||
|
||||
it('lässt amount unverändert wenn die Erstbestellung keine Einmal-Artikel enthält', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
$orderData = aboCreateOrderWithAddon($fixture);
|
||||
ShoppingOrderItem::where('shopping_order_id', $orderData['order']->id)
|
||||
->where('is_abo_addon', true)
|
||||
->delete();
|
||||
|
||||
AboHelper::createNewAbo($orderData['payment']);
|
||||
|
||||
expect((int) UserAbo::first()->amount)->toBe(10000);
|
||||
});
|
||||
|
||||
it('gruppiert Erstrechnung und Lieferschein der Anlage-Bestellung nach Einmal-Artikeln', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
$orderData = aboCreateOrderWithAddon($fixture);
|
||||
$order = $orderData['order']->fresh('shopping_order_items.product');
|
||||
|
||||
$invoiceHtml = view('pdf.invoice-detail', ['shopping_order' => $order])->render();
|
||||
$deliveryHtml = view('pdf.delivery-detail', ['shopping_order' => $order])->render();
|
||||
|
||||
foreach ([$invoiceHtml, $deliveryHtml] as $html) {
|
||||
expect($html)->toContain(__('pdf.abo_items_heading'))
|
||||
->and($html)->toContain(__('pdf.abo_addon_items_heading'));
|
||||
expect(strpos($html, __('pdf.abo_items_heading')))
|
||||
->toBeLessThan(strpos($html, __('pdf.abo_addon_items_heading')));
|
||||
}
|
||||
});
|
||||
|
||||
it('überspringt Einmal-Artikel auch bei ensureUserAboItemsFromLatestOrder', function () {
|
||||
$fixture = aboCreateOneTimeFixture();
|
||||
$orderData = aboCreateOrderWithAddon($fixture);
|
||||
|
||||
$userAbo = UserAbo::create([
|
||||
'user_id' => $fixture['user']->id,
|
||||
'member_id' => $fixture['user']->id,
|
||||
'shopping_user_id' => $fixture['shoppingUser']->id,
|
||||
'email' => $fixture['shoppingUser']->billing_email,
|
||||
'is_for' => 'ot',
|
||||
'payone_userid' => 987654,
|
||||
'clearingtype' => 'cc',
|
||||
'status' => 1,
|
||||
'abo_interval' => 15,
|
||||
'next_date' => now()->addDays(20)->toDateString(),
|
||||
]);
|
||||
UserAboOrder::create([
|
||||
'user_abo_id' => $userAbo->id,
|
||||
'shopping_order_id' => $orderData['order']->id,
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
expect(AboHelper::ensureUserAboItemsFromLatestOrder($userAbo))->toBeTrue();
|
||||
|
||||
$items = UserAboItem::where('user_abo_id', $userAbo->id)->get();
|
||||
expect($items)->toHaveCount(1)
|
||||
->and((int) $items->first()->product_id)->toBe($orderData['aboProductId']);
|
||||
});
|
||||
129
tests/Feature/AboPortalCreateOneTimeTest.php
Normal file
129
tests/Feature/AboPortalCreateOneTimeTest.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Einmalprodukte im Portal-Abo-Wizard (Anlage, Variante A):
|
||||
* onetime_product_qty[] aus dem Upgrade-Schritt landet als getrennte Cart-Zeile
|
||||
* (Option abo_addon) im subscription-Yard; Basis-/Upgrade-Logik bleibt unberührt.
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Portal\AboController;
|
||||
use App\Models\Country;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShippingPrice;
|
||||
use App\Services\AboHelper;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function makePortalOneTimeEnv(): ShippingCountry
|
||||
{
|
||||
$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]);
|
||||
ShippingPrice::create([
|
||||
'shipping_id' => $shipping->id, 'price' => 4.90, 'price_comp' => 0, 'num_comp' => 0,
|
||||
'tax_rate' => 19, 'weight_from' => 1, 'weight_to' => 100000,
|
||||
]);
|
||||
|
||||
return ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
|
||||
}
|
||||
|
||||
function callPortalAboMethod(string $method, array $args = [])
|
||||
{
|
||||
$controller = app(AboController::class);
|
||||
$reflection = new ReflectionMethod($controller, $method);
|
||||
|
||||
return $reflection->invoke($controller, ...$args);
|
||||
}
|
||||
|
||||
function findSubscriptionRows(int $productId, bool $addon): Collection
|
||||
{
|
||||
return Yard::instance('subscription')->search(function ($item) use ($productId, $addon) {
|
||||
return (int) $item->id === $productId && (bool) ($item->options->abo_addon ?? false) === $addon;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->shippingCountry = makePortalOneTimeEnv();
|
||||
$yard = Yard::instance('subscription');
|
||||
$yard->destroy();
|
||||
$yard->setShippingCountryWithPrice($this->shippingCountry->id, 'abo-ot-customer');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Yard::instance('subscription')->destroy();
|
||||
});
|
||||
|
||||
it('übernimmt onetime_product_qty als getrennte Einmal-Cart-Zeilen', function () {
|
||||
$product = makeProduct(['3'], 25.50, 19);
|
||||
|
||||
request()->replace(['onetime_product_qty' => [$product->id => 2]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
$rows = findSubscriptionRows($product->id, true);
|
||||
expect($rows)->toHaveCount(1)
|
||||
->and((int) $rows->first()->qty)->toBe(2)
|
||||
->and((bool) $rows->first()->options->abo_addon)->toBeTrue();
|
||||
});
|
||||
|
||||
it('ignoriert Produkte außerhalb des Einmal-Sortiments', function () {
|
||||
$aboOnlyProduct = makeProduct(['12'], 90, 19);
|
||||
|
||||
request()->replace(['onetime_product_qty' => [$aboOnlyProduct->id => 1]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
expect(Yard::instance('subscription')->content())->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('entfernt mit Menge 0 nur die Einmal-Zeile, die Abo-Zeile bleibt', function () {
|
||||
$product = makeProduct(['3', '12'], 90, 19);
|
||||
|
||||
callPortalAboMethod('addProductToCart', [$product, 1]);
|
||||
request()->replace(['onetime_product_qty' => [$product->id => 2]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
expect(findSubscriptionRows($product->id, true))->toHaveCount(1)
|
||||
->and(findSubscriptionRows($product->id, false))->toHaveCount(1);
|
||||
|
||||
request()->replace(['onetime_product_qty' => [$product->id => 0]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
expect(findSubscriptionRows($product->id, true))->toHaveCount(0)
|
||||
->and(findSubscriptionRows($product->id, false))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('lässt die Basis-/Upgrade-Logik Einmal-Zeilen unangetastet', function () {
|
||||
$product = makeProduct(['3', '13'], 50, 19);
|
||||
|
||||
request()->replace(['onetime_product_qty' => [$product->id => 3]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
callPortalAboMethod('addProductToCart', [$product, 1]);
|
||||
|
||||
$addonRows = findSubscriptionRows($product->id, true);
|
||||
$regularRows = findSubscriptionRows($product->id, false);
|
||||
|
||||
expect($addonRows)->toHaveCount(1)
|
||||
->and((int) $addonRows->first()->qty)->toBe(3)
|
||||
->and($regularRows)->toHaveCount(1)
|
||||
->and((int) $regularRows->first()->qty)->toBe(1);
|
||||
});
|
||||
|
||||
it('zählt Einmal-Zeilen nicht als Basis-Produkt', function () {
|
||||
$addonWithBaseShowOn = makeProduct(['3', '12'], 90, 19);
|
||||
|
||||
request()->replace(['onetime_product_qty' => [$addonWithBaseShowOn->id => 1]]);
|
||||
callPortalAboMethod('oneTimeProductsToCart');
|
||||
|
||||
expect(AboHelper::aboHasBaseProduct(Yard::instance('subscription')->getContentByOrder()))->toBeFalse();
|
||||
|
||||
$baseProduct = makeProduct(['12'], 90, 19);
|
||||
callPortalAboMethod('addProductToCart', [$baseProduct, 1]);
|
||||
|
||||
expect(AboHelper::aboHasBaseProduct(Yard::instance('subscription')->getContentByOrder()))->toBeTrue();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue