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:
Kevin 2026-07-21 14:36:40 +00:00
parent 1e7ae92d0c
commit cdb121fde0
32 changed files with 1479 additions and 52 deletions

View file

@ -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,