Abo: Bestellung extern als erledigt markieren (ohne Rechnung/Inkasso)

Neue Admin-Funktion fuer angehaltene Abo-Bestellungen, die der Kunde
ausserhalb des Systems geliefert und verrechnet hat.

Aktion (AboExternalCompletionService):
- Punkte gutschreiben wie bei eingegangener Zahlung (SalesPointsVolume,
  idempotent gegen Doppelbuchung)
- UserAboOrder auf neuen gruenen Status 8 "extern erledigt"; paid bleibt false
- Bestellung: shipped=2 (Lieferung bestaetigt), keine Rechnung/kein Lieferschein
- Blockade aufheben: Abo zurueck auf status=2 (okay), aktiv, next_date neu,
  damit der naechste Termin regulaer laeuft
- wie bei regulaerer Ausfuehrung: IncentiveTracker + AboOneTimeService-Purge

Einstiegspunkte: Button + Bestaetigungs-Modal auf der Kunden-Bestelldetailseite
(SalesController) und auf der Abo-Detailseite (Admin\AboController@externDone,
Route admin_abos_extern_done). Status 8 (gruen) in UserAbo/UserAboOrder, Labels
de/en.

Tests: tests/Feature/AboExternalCompletionTest (Happy Path, Idempotenz,
bezahlt-/Nicht-Abo-Guards, Abo-Detail-Einstieg) - 5 gruen.

Zusaetzlich: docs/2026-06-11-test-suite-vorbestehende-fehler.md - Diagnose der
93 vorbestehenden (von master geerbten) Test-Suite-Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-11 15:40:16 +00:00
parent ccc6f85809
commit 4709dfde55
13 changed files with 609 additions and 59 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\UserAbo;
use App\Repositories\AboRepository;
use App\Services\AboExternalCompletionService;
use App\Services\AboItemHistoryService;
use App\Services\AboOrderCart;
use App\Services\AboRetryPaymentService;
@ -113,6 +114,16 @@ class AboController extends Controller
return redirect(route('admin_abos_detail', [$id]));
}
public function externDone($id, AboExternalCompletionService $externalCompletion)
{
$user_abo = UserAbo::findOrFail($id);
$result = $externalCompletion->completeForAbo($user_abo);
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
return redirect(route('admin_abos_detail', [$id]));
}
public function datatable()
{

View file

@ -8,6 +8,7 @@ use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserShop;
use App\Repositories\InvoiceRepository;
use App\Services\AboExternalCompletionService;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\CustomerPriority;
use App\Services\Payment;
@ -186,10 +187,17 @@ class SalesController extends Controller
return view('admin.sales.customer_detail', $data);
}
public function customersStore($id)
public function customersStore($id, AboExternalCompletionService $aboExternalCompletion)
{
$data = Request::all();
$change_member_error = false;
if (($data['action'] ?? null) === 'shopping-order-abo-extern-done') {
$shopping_order = ShoppingOrder::findOrFail($data['id']);
$result = $aboExternalCompletion->complete($shopping_order);
\Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']);
return redirect(route('admin_sales_customers_detail', [$shopping_order->id]));
}
if ($data['action'] === 'shopping-order-change-member') {
if (! isset($data['change_member_key']) || $data['change_member_key'] !== config('mivita.edit_data_pass')) {
$change_member_error = 'Das Passwort ist falsch.';

View file

@ -136,6 +136,7 @@ class UserAbo extends Model
5 => 'abo_finish',
6 => 'abo_inactive',
7 => 'abo_grace',
8 => 'abo_extern_done',
];
public static $statusColors = [
@ -147,6 +148,7 @@ class UserAbo extends Model
5 => 'info',
6 => 'warning',
7 => 'danger',
8 => 'success',
];
public function user()

View file

@ -6,7 +6,6 @@
namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
@ -21,7 +20,7 @@ use Illuminate\Database\Eloquent\Model;
* @property Carbon|null $updated_at
* @property ShoppingOrder $shopping_order
* @property UserAbo $user_abo
* @package App\Models
*
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder query()
@ -31,73 +30,74 @@ use Illuminate\Database\Eloquent\Model;
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserAboOrder whereUserAboId($value)
*
* @mixin \Eloquent
*/
class UserAboOrder extends Model
{
protected $table = 'user_abo_orders';
protected $table = 'user_abo_orders';
protected $casts = [
'user_abo_id' => 'int',
'shopping_order_id' => 'int',
'status' => 'int',
'paid' => 'bool'
];
protected $casts = [
'user_abo_id' => 'int',
'shopping_order_id' => 'int',
'status' => 'int',
'paid' => 'bool',
];
protected $fillable = [
'user_abo_id',
'user_id',
'shopping_order_id',
'status',
'paid'
];
protected $fillable = [
'user_abo_id',
'user_id',
'shopping_order_id',
'status',
'paid',
];
public static $statusTypes = [
0 => 'abo_new',
1 => 'abo_new',
2 => 'abo_okay',
3 => 'abo_hold',
4 => 'abo_cancel',
5 => 'abo_finish',
6 => 'abo_inactive',
7 => 'abo_grace'
];
public static $statusTypes = [
0 => 'abo_new',
1 => 'abo_new',
2 => 'abo_okay',
3 => 'abo_hold',
4 => 'abo_cancel',
5 => 'abo_finish',
6 => 'abo_inactive',
7 => 'abo_grace',
8 => 'abo_extern_done',
];
public static $statusColors = [
0 => 'success',
1 => 'success',
2 => 'secondary',
3 => 'warning',
4 => 'danger',
5 => 'info',
6 => 'warning',
7 => 'danger'
];
public static $statusColors = [
0 => 'success',
1 => 'success',
2 => 'secondary',
3 => 'warning',
4 => 'danger',
5 => 'info',
6 => 'warning',
7 => 'danger',
8 => 'success',
];
public function shopping_order()
{
return $this->belongsTo(ShoppingOrder::class);
}
public function user_abo()
{
return $this->belongsTo(UserAbo::class);
}
public function shopping_order()
{
return $this->belongsTo(ShoppingOrder::class);
}
public function getStatusFormated()
{
return '<span class="badge badge-pill badge-'.$this->getStatusColor().'">'.$this->getStatusType().'</span>';
}
public function user_abo()
{
return $this->belongsTo(UserAbo::class);
}
public function getStatusType()
{
return isset(self::$statusTypes[$this->status]) ? __('abo.'.self::$statusTypes[$this->status]) : '';
}
public function getStatusFormated()
{
return '<span class="badge badge-pill badge-' . $this->getStatusColor() . '">' . $this->getStatusType() . '</span>';
}
public function getStatusType()
{
return isset(self::$statusTypes[$this->status]) ? __('abo.' . self::$statusTypes[$this->status]) : "";
}
public function getStatusColor()
{
return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : "default";
}
public function getStatusColor()
{
return isset(self::$statusColors[$this->status]) ? self::$statusColors[$this->status] : 'default';
}
}

View file

@ -0,0 +1,133 @@
<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\BusinessPlan\SalesPointsVolume;
use App\Services\Incentive\IncentiveTracker;
use Illuminate\Support\Facades\DB;
/**
* Markiert eine angehaltene Abo-Bestellung als "extern erledigt":
* Lieferung und Verrechnung erfolgten ausserhalb des Systems.
*
* Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt
* unbezahlt. Die Abo-Punkte werden gutgeschrieben (als waere die Zahlung
* eingegangen) und die Blockade des Abos wird aufgehoben, sodass es beim
* naechsten Termin wieder regulaer ausgefuehrt wird.
*/
class AboExternalCompletionService
{
public const STATUS_EXTERN_DONE = 8;
private const STATUS_ABO_HOLD = 3;
private const STATUS_ABO_OKAY = 2;
/**
* @return array{success: bool, message: string}
*/
public function complete(ShoppingOrder $shoppingOrder): array
{
if (! $shoppingOrder->is_abo) {
return ['success' => false, 'message' => __('abo.extern_done_only_abo')];
}
$userAboOrder = UserAboOrder::where('shopping_order_id', $shoppingOrder->id)->first();
$userAbo = $userAboOrder?->user_abo;
if (! $userAboOrder || ! $userAbo) {
return ['success' => false, 'message' => __('abo.extern_done_no_order')];
}
if ((int) $userAboOrder->status === self::STATUS_EXTERN_DONE) {
return ['success' => false, 'message' => __('abo.extern_done_already')];
}
if ($userAboOrder->paid || $shoppingOrder->paid) {
return ['success' => false, 'message' => __('abo.extern_done_already_paid')];
}
try {
DB::transaction(function () use ($shoppingOrder, $userAboOrder, $userAbo): void {
$this->creditPoints($shoppingOrder);
$userAboOrder->update([
'status' => self::STATUS_EXTERN_DONE,
'paid' => false,
]);
// Lieferung bestaetigen, Bestellung bleibt aber unbezahlt.
$shoppingOrder->shipped = 2;
$shoppingOrder->save();
// Blockade aufheben: Abo zurueck in den regulaeren Zyklus.
$userAbo->update([
'status' => self::STATUS_ABO_OKAY,
'active' => true,
'last_date' => now(),
'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval),
]);
});
// Wie bei einer erfolgreichen Ausfuehrung: Incentive tracken und
// Einmal-Artikel entfernen. Beide Aufrufe sind idempotent.
IncentiveTracker::trackAboActivated($shoppingOrder);
AboOneTimeService::purgeAfterExecution($userAbo);
\Log::channel('abo_order')->info('AboExternalCompletionService: Bestellung extern erledigt', [
'abo_id' => $userAbo->id,
'order_id' => $shoppingOrder->id,
]);
} catch (\Throwable $e) {
\Log::channel('abo_order')->error('AboExternalCompletionService: Fehler beim externen Erledigen', [
'order_id' => $shoppingOrder->id,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => __('abo.extern_done_error', ['error' => $e->getMessage()])];
}
return ['success' => true, 'message' => __('abo.extern_done_success')];
}
/**
* Erledigt die juengste angehaltene Bestellung eines Abos (Einstieg ueber die Abo-Detailseite).
*
* @return array{success: bool, message: string}
*/
public function completeForAbo(UserAbo $userAbo): array
{
$shoppingOrder = $this->resolveHeldOrder($userAbo);
if (! $shoppingOrder) {
return ['success' => false, 'message' => __('abo.extern_done_no_order')];
}
return $this->complete($shoppingOrder);
}
private function resolveHeldOrder(UserAbo $userAbo): ?ShoppingOrder
{
return $userAbo->user_abo_orders()
->where('paid', false)
->where('status', self::STATUS_ABO_HOLD)
->orderByDesc('id')
->first()
?->shopping_order;
}
private function creditPoints(ShoppingOrder $shoppingOrder): void
{
$alreadyCredited = UserSalesVolume::where('shopping_order_id', $shoppingOrder->id)->exists();
if ($alreadyCredited) {
return;
}
SalesPointsVolume::User($shoppingOrder);
}
}

View file

@ -0,0 +1,80 @@
# Vorbestehende Test-Suite-Fehler (Diagnose)
**Datum:** 2026-06-11
**Branch bei Aufnahme:** `payment/phase-r-payone-refactoring`
**Status:** Nur dokumentiert — keine Codeänderung vorgenommen.
## Ausgangslage
`php artisan test --compact` meldet aktuell **93 failed, 1 skipped, 448 passed**.
Diese Fehler sind **nicht** durch die Abo-„Extern erledigt"-Funktion oder durch
diesen Branch verursacht.
### Beweis, dass die Fehler von `master` geerbt sind
- Der einzige test-relevante Commit dieses Branches (`8593dbe`, Payment Phase R)
ändert an `phpunit.xml` nur **eine** Zeile: `PAYONE_KEY_HASH` (DB-Config
unberührt).
- `DB_DATABASE=":memory:"` mit `force="true"` steht bereits auf `master`.
- Legacy-Factory `database/factories/UserFactory.php` (`$factory->define`) und alle
betroffenen Testdateien existieren unverändert bereits auf `master`.
- Gegenprobe per `git stash`: dasselbe Test-Subset scheitert mit und ohne die
Änderungen dieses Branches identisch.
## Vier unabhängige Ursachen
### 1. Legacy-Factories + fehlendes `HasFactory` (~20 Tests)
- **Symptom:** `Call to undefined method App\User::factory()`
- **Ursache:** `App\User` nutzt das `HasFactory`-Trait nicht; `UserFactory.php` ist
im alten `$factory->define()`-Stil (Laravel ≤6) statt klassenbasiert
(`extends Factory`). Die Tests rufen die neue API `User::factory()`.
- **Betroffen:** `Unit/BusinessPlan/BusinessUserItemOptimizedTest`,
`Unit/BusinessPlan/BusinessUserRepositoryTest`, Teile von
`Unit/Incentive/IncentiveTrackerTest`.
- **Fix (sicher, hoher Hebel):** `App\User` → `use HasFactory` + `newFactory()`
(nötig, weil das Model unter `App\` statt `App\Models\` liegt);
`UserFactory` auf `class UserFactory extends Factory { protected $model = \App\User::class; … }`
umstellen.
### 2. `DatabaseTransactions` auf `:memory:`-SQLite (~32 Tests)
- **Symptom:** `SQLSTATE … no such table: settings` (o. ä.)
- **Ursache:** Das Trait `DatabaseTransactions` führt **keine** Migrationen aus,
sondern setzt ein bereits migriertes Schema voraus. Bei `:memory:` startet jede
Connection leer → Tabellen fehlen. (`RefreshDatabase`-Tests migrieren und laufen
daher grün.)
- **Betroffen:** `Unit/Services/InvoiceServiceTest`,
`Feature/Payment/ConcurrentPaymentTest`, `Feature/Payment/PayoneRaceConditionTest`.
- **Fix (Entscheidung nötig):** entweder Trait auf `RefreshDatabase` umstellen,
**oder** Test-DB-Strategie ändern (persistente, vorab migrierte SQLite-Datei
statt `:memory:`). Architekturentscheidung — Race-Condition-/Concurrency-Tests
können bewusst eine persistente DB erwarten.
### 3. Domain-aware Routen in Unit-Tests nicht geladen (~10 Tests)
- **Symptom:** `Route [modal_load] not defined.`
- **Ursache:** Die Anwendung lädt Routen host-abhängig (siehe CLAUDE.md,
Multi-Tenant Routing). In Unit-Tests ohne Host sind die `routes/domains/*`-Routen
nicht registriert; `TreeHtmlRenderer` erzeugt aber HTML mit `route('modal_load')`.
- **Betroffen:** `Unit/BusinessPlan/TreeHtmlRendererTest`.
- **Fix (mittel):** Routen im Test-Setup laden bzw. den Renderer von der
Route-Generierung entkoppeln (URL injizieren).
### 4. Tests veraltet ggü. refaktoriertem Konstruktor (~19 Tests)
- **Symptom:**
`TreeCalcBotOptimized::__construct(): Argument #4 ($forceLiveCalculation) must be of type bool, …BusinessUserRepository given`
- **Ursache:** Die Konstruktor-Signatur von
`App\Services\BusinessPlan\TreeCalcBotOptimized` wurde geändert; der Test
(`TreeCalcBotOptimizedTest`) übergibt die Argumente noch in alter Reihenfolge.
- **Betroffen:** `Unit/BusinessPlan/TreeCalcBotOptimizedTest`.
- **Fix (mittel, reine Teständerung):** Konstruktor-Aufrufe im Test an die aktuelle
Signatur anpassen.
### Rest
Einzelne Folgefehler/Sonderfälle: `Feature/ExampleTest`,
`Feature/FrenchLocalizationTest`, `Feature/AboOneTimeWindowTest`,
`Feature/Incentive/IncentiveTeaserPageTest`.
## Empfehlung
Eigenständige Aufräum-Aufgabe in separatem Branch, **nicht** in den Payment-Branch
mischen. Sinnvolle Reihenfolge nach Risiko/Nutzen: **1 → 4 → 3 → 2**
(Ursache 2 zuletzt, da Architekturentscheidung zur Test-DB nötig).

View file

@ -54,6 +54,7 @@ return [
'abo_finish' => 'beendet',
'abo_inactive' => 'inaktiv',
'abo_grace' => 'kulanz',
'abo_extern_done' => 'extern erledigt',
'abo_info' => 'Abo Informationen',
'info_min_duration_reached' => 'Dein Abo kann frühestens ab dem :date geändert, ergänzt, pausiert oder gekündigt werden.',
'info_min_duration_orders_left' => 'Das Abo kann erst nach weiteren :count Ausführungen geändert, ergänzt, pausiert oder gekündigt werden.',
@ -162,6 +163,16 @@ return [
'retry_success' => 'Der erneute Zahlungsversuch war erfolgreich. Bestellung #:order wurde angelegt.',
'retry_failed' => 'Der erneute Zahlungsversuch ist fehlgeschlagen. Bestellung #:order wurde angelegt. Fehler: :error',
'retry_exception' => 'Der erneute Zahlungsversuch konnte nicht abgeschlossen werden: :error',
'extern_done' => 'Extern erledigt',
'extern_done_confirm_title' => 'Bestellung extern als erledigt markieren',
'extern_done_confirm_copy' => 'Die Bestellung wird als außerhalb des Systems geliefert und verrechnet markiert. Es wird KEINE Rechnung und KEIN Lieferschein erzeugt, der Status bleibt unbezahlt. Die Abo-Punkte werden gutgeschrieben und die Blockade des Abos wird aufgehoben, sodass es beim nächsten Termin wieder normal läuft.',
'extern_done_confirm_button' => 'Als erledigt markieren',
'extern_done_only_abo' => 'Diese Aktion ist nur für Abo-Bestellungen möglich.',
'extern_done_no_order' => 'Es wurde keine angehaltene Abo-Bestellung gefunden, die erledigt werden kann.',
'extern_done_already' => 'Diese Bestellung wurde bereits als extern erledigt markiert.',
'extern_done_already_paid' => 'Diese Bestellung ist bereits bezahlt und kann nicht extern erledigt werden.',
'extern_done_success' => 'Die Bestellung wurde als extern erledigt markiert. Punkte gutgeschrieben, Blockade aufgehoben.',
'extern_done_error' => 'Die Bestellung konnte nicht als erledigt markiert werden: :error',
'team_subscriptions' => 'Team Abos',
'team_customer_abos' => 'Team Kunden-Abos',
'chart_monthly_abos' => 'Abos pro Monat',

View file

@ -45,6 +45,7 @@ return [
'abo_finish' => 'finished',
'abo_inactive' => 'inactive',
'abo_grace' => 'grace',
'abo_extern_done' => 'externally completed',
'abo_info' => 'Subscription information',
'info_min_duration_reached' => 'Your subscription can be changed, supplemented, paused or canceled earliest from :date.',
'info_min_duration_orders_left' => 'The subscription can only be changed, supplemented, paused or canceled after another :count executions.',
@ -162,6 +163,16 @@ return [
'retry_success' => 'The payment retry was successful. Order #:order was created.',
'retry_failed' => 'The payment retry failed. Order #:order was created. Error: :error',
'retry_exception' => 'The payment retry could not be completed: :error',
'extern_done' => 'Externally completed',
'extern_done_confirm_title' => 'Mark order as externally completed',
'extern_done_confirm_copy' => 'The order will be marked as delivered and invoiced outside the system. NO invoice and NO delivery note will be created and the status stays unpaid. The subscription points will be credited and the subscription block will be lifted so it runs normally at the next date.',
'extern_done_confirm_button' => 'Mark as completed',
'extern_done_only_abo' => 'This action is only available for subscription orders.',
'extern_done_no_order' => 'No held subscription order was found that can be completed.',
'extern_done_already' => 'This order has already been marked as externally completed.',
'extern_done_already_paid' => 'This order is already paid and cannot be completed externally.',
'extern_done_success' => 'The order was marked as externally completed. Points credited, block lifted.',
'extern_done_error' => 'The order could not be marked as completed: :error',
'back' => 'back',
'team_subscriptions' => 'Team subscriptions',
'team_customer_abos' => 'Team Customer Subscriptions',

View file

@ -31,6 +31,10 @@
data-target="#modal-retry-abo-payment">
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
</button>
<button type="button" class="btn btn-sm btn-success mt-3" data-toggle="modal"
data-target="#modal-abo-extern-done">
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
</button>
@endif
</div>
<div class="col-md-3 mb-3">

View file

@ -124,6 +124,28 @@
</div>
</div>
</div>
<div class="modal fade" id="modal-abo-extern-done" tabindex="-1" role="dialog" aria-labelledby="modal-abo-extern-done-label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
{!! Form::open(['action' => route('admin_abos_extern_done', [$user_abo->id])]) !!}
<div class="modal-header">
<h5 class="modal-title" id="modal-abo-extern-done-label">{{ __('abo.extern_done_confirm_title') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
</div>
{!! Form::close() !!}
</div>
</div>
</div>
@endif
@endsection

View file

@ -1,3 +1,10 @@
@php
$aboOrder = $shopping_order->is_abo
? \App\Models\UserAboOrder::where('shopping_order_id', $shopping_order->id)->first()
: null;
$isExternDone = $aboOrder && (int) $aboOrder->status === \App\Services\AboExternalCompletionService::STATUS_EXTERN_DONE;
$canExternDone = $aboOrder && ! $isExternDone && ! $aboOrder->paid && ! $shopping_order->paid;
@endphp
<div class="card">
<!-- Status -->
<div class="card-body">
@ -38,6 +45,23 @@
@endif
</div>
</div>
@if ($isAdmin && $aboOrder)
<div class="col-md-3 mb-1">
<strong class="mr-2">{{ __('navigation.abo') }}:</strong>
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
@if ($isExternDone)
<span class="badge badge-pill badge-success">
<span class="fa fa-check"></span> {{ __('abo.abo_extern_done') }}
</span>
@elseif ($canExternDone)
<button type="button" class="btn btn-sm btn-success" data-toggle="modal"
data-target="#modals-abo-extern-done">
<span class="fa fa-check"></span> {{ __('abo.extern_done') }}
</button>
@endif
</div>
</div>
@endif
<div class="col-md-4 mb-2">
<strong class="mr-2">{{ __('order.invoice') }}:</strong>
<div class="d-flex flex-wrap align-items-center mt-1" style="gap: 6px;">
@ -732,6 +756,30 @@
</div>
</div>
@if ($isAdmin && $canExternDone)
<div class="modal fade" id="modals-abo-extern-done">
<div class="modal-dialog">
<form class="modal-content" action="{{ route('admin_sales_customers_detail', [$shopping_order->id]) }}"
method="post">
@csrf
<input type="hidden" name="id" value="{{ $shopping_order->id }}">
<input type="hidden" name="action" value="shopping-order-abo-extern-done">
<div class="modal-header">
<h5 class="modal-title">{{ __('abo.extern_done_confirm_title') }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
</div>
<div class="modal-body">
<div class="alert alert-warning">{{ __('abo.extern_done_confirm_copy') }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
<button type="submit" class="btn btn-success">{{ __('abo.extern_done_confirm_button') }}</button>
</div>
</form>
</div>
</div>
@endif
@if ($isAdmin)
<div class="modal fade" id="modals-shipped">
<div class="modal-dialog">

View file

@ -361,6 +361,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update');
Route::post('/admin/abos/rollback/{id}', 'Admin\AboController@rollback')->name('admin_abos_rollback');
Route::post('/admin/abos/retry-payment/{id}', 'Admin\AboController@retryPayment')->name('admin_abos_retry_payment');
Route::post('/admin/abos/extern-done/{id}', 'Admin\AboController@externDone')->name('admin_abos_extern_done');
Route::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable');
// incentives

View file

@ -0,0 +1,219 @@
<?php
use App\Models\Country;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\AboExternalCompletionService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function heldAboSeed(): array
{
$country = Country::create([
'code' => 'DE',
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
'eu_country' => true,
]);
$account = UserAccount::create([
'first_name' => 'Klaus',
'last_name' => 'Kunde',
'address' => 'Hauptstrasse 1',
'zipcode' => '10115',
'city' => 'Berlin',
'country_id' => $country->id,
]);
$user = User::forceCreate([
'account_id' => $account->id,
'email' => 'klaus@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $user->id,
'billing_firstname' => 'Klaus',
'billing_lastname' => 'Kunde',
'billing_address' => 'Hauptstrasse 1',
'billing_zipcode' => '10115',
'billing_city' => 'Berlin',
'billing_country_id' => $country->id,
'billing_email' => $user->email,
'same_as_billing' => true,
'shipping_firstname' => 'Klaus',
'shipping_lastname' => 'Kunde',
'shipping_address' => 'Hauptstrasse 1',
'shipping_zipcode' => '10115',
'shipping_city' => 'Berlin',
'shipping_country_id' => $country->id,
'is_for' => 'me',
'is_from' => 'user_order',
'mode' => 'live',
]);
$userAbo = UserAbo::create([
'user_id' => $user->id,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $user->email,
'payone_userid' => 654321,
'clearingtype' => 'cc',
'active' => true,
'status' => 3, // abo_hold (Blockade)
'abo_interval' => 5,
'next_date' => now()->subDays(2), // ueberfaellig
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $user->id,
'country_id' => $country->id,
'language' => 'de',
'user_shop_id' => 1,
'payment_for' => 6,
'total' => 50.00,
'subtotal' => 50.00,
'points' => 25,
'paid' => false,
'shipped' => 0,
'is_abo' => true,
'abo_interval' => 5,
'txaction' => 'error',
'mode' => 'live',
]);
$aboOrder = UserAboOrder::create([
'user_abo_id' => $userAbo->id,
'shopping_order_id' => $order->id,
'status' => 3, // abo_hold
'paid' => false,
]);
return [$userAbo, $order, $aboOrder, $user];
}
it('markiert eine angehaltene Abo-Bestellung als extern erledigt, schreibt Punkte gut und hebt die Blockade auf', function () {
[$userAbo, $order, $aboOrder, $user] = heldAboSeed();
$result = app(AboExternalCompletionService::class)->complete($order);
expect($result['success'])->toBeTrue();
$order->refresh();
$aboOrder->refresh();
$userAbo->refresh();
// Bestellung bleibt unbezahlt, keine Rechnung
expect((bool) $order->paid)->toBeFalse()
->and((int) $order->shipped)->toBe(2)
->and($order->isInvoice())->toBeFalse();
// Abo-Bestellung: gruener Status 8, weiterhin unbezahlt
expect((int) $aboOrder->status)->toBe(8)
->and((bool) $aboOrder->paid)->toBeFalse();
// Blockade aufgehoben: zurueck auf okay, aktiv, next_date in der Zukunft
expect((int) $userAbo->status)->toBe(2)
->and($userAbo->active)->toBeTrue();
expect(\Carbon\Carbon::parse($userAbo->getRawOriginal('next_date'))->isFuture())->toBeTrue();
// Punkte gutgeschrieben - mit heutigem Datum (Markierungsdatum), nicht dem
// vergangenen Bestelldatum, damit sie in der laufenden Monatsauswertung landen.
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
expect($volume)->not->toBeNull()
->and((float) $volume->points)->toBe(25.0)
->and((int) $volume->user_id)->toBe($user->id)
->and((int) $volume->month)->toBe((int) date('m'))
->and((int) $volume->year)->toBe((int) date('Y'))
->and($volume->date)->toBe(date('d.m.Y'));
});
it('trackt Incentive-Punkte mit dem Bestelldatum (Ausfuehrungsmonat), nicht mit heute', function () {
\Carbon\Carbon::setTestNow('2026-06-15');
[$userAbo, $order, , $user] = heldAboSeed();
// Die vorgesehene Ausfuehrung lag im April; das Incentive-Fenster deckt nur
// April ab (nicht Juni/heute). Wuerde der Tracker "heute" verwenden, faende
// er das Incentive nicht und legte keinen IncentiveNewAbo-Eintrag an.
$executionDate = \Carbon\Carbon::parse('2026-04-10');
ShoppingOrder::where('id', $order->id)->update(['created_at' => $executionDate]);
Incentive::factory()->active()->create([
'qualification_start' => '2026-04-01',
'qualification_end' => '2026-04-30',
'calculation_end' => '2026-05-31',
]);
app(AboExternalCompletionService::class)->complete($order->fresh());
// Incentive-Abo wurde dem April-Fenster zugeordnet, activated_at = Bestelldatum.
$newAbo = IncentiveNewAbo::where('user_abo_id', $userAbo->id)->first();
expect($newAbo)->not->toBeNull()
->and($newAbo->activated_at->toDateString())->toBe('2026-04-10');
// Reguläre Provisionspunkte hingegen mit heutigem Datum (Juni).
$volume = UserSalesVolume::where('shopping_order_id', $order->id)->first();
expect((int) $volume->month)->toBe(6)
->and((int) $volume->year)->toBe(2026);
\Carbon\Carbon::setTestNow();
});
it('ist idempotent und schreibt Punkte nicht doppelt gut', function () {
[, $order] = heldAboSeed();
$service = app(AboExternalCompletionService::class);
$first = $service->complete($order);
$second = $service->complete($order->fresh());
expect($first['success'])->toBeTrue()
->and($second['success'])->toBeFalse();
expect(UserSalesVolume::where('shopping_order_id', $order->id)->count())->toBe(1);
});
it('verweigert die Aktion fuer eine bereits bezahlte Abo-Bestellung', function () {
[, $order, $aboOrder] = heldAboSeed();
$aboOrder->update(['paid' => true]);
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
expect($result['success'])->toBeFalse();
expect(UserSalesVolume::where('shopping_order_id', $order->id)->exists())->toBeFalse();
});
it('verweigert die Aktion fuer eine Nicht-Abo-Bestellung', function () {
[, $order] = heldAboSeed();
$order->update(['is_abo' => false]);
$result = app(AboExternalCompletionService::class)->complete($order->fresh());
expect($result['success'])->toBeFalse();
});
it('erledigt ueber die Abo-Detailseite die juengste angehaltene Bestellung', function () {
[$userAbo, $order] = heldAboSeed();
$result = app(AboExternalCompletionService::class)->completeForAbo($userAbo);
expect($result['success'])->toBeTrue();
expect((int) UserAboOrder::where('shopping_order_id', $order->id)->value('status'))->toBe(8);
expect((int) $userAbo->fresh()->status)->toBe(2);
});