From 245c2815413a95d4ef19ca9135fe16c23ff90aa9 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 8 May 2026 15:34:57 +0200 Subject: [PATCH] Steuerberater Modul tax --- app/Cron/UserMakeOrder.php | 29 ++ app/Http/Controllers/Admin/AboController.php | 11 + app/Http/Controllers/User/AboController.php | 15 +- app/Repositories/AboRepository.php | 53 ++- app/Repositories/CheckoutRepository.php | 1 + app/Services/AboRetryPaymentService.php | 238 ++++++++++++++ app/Services/DatevExportService.php | 94 ++++-- .../steuerberater-abgleich-status.csv | 17 + .../steuerberater-abgleich-status.html | 310 ++++++++++++++++++ .../steuerberater-abgleich-status.md | 58 ++++ dev/steuerberater/steuerberater.csv | 67 ++++ resources/lang/de/abo.php | 13 + resources/lang/en/abo.php | 13 + resources/lang/es/abo.php | 13 + resources/views/admin/abo/_detail.blade.php | 209 ++++++------ .../views/admin/abo/_executions.blade.php | 28 ++ resources/views/admin/abo/detail.blade.php | 57 +++- .../admin/abo/modal_abo_update.blade.php | 28 +- routes/domains/crm.php | 1 + tests/Feature/AboRepositoryDateChangeTest.php | 108 +++++- tests/Feature/AdminAboRetryPaymentTest.php | 207 ++++++++++++ .../Unit/Services/DatevExportServiceTest.php | 58 ++++ 22 files changed, 1489 insertions(+), 139 deletions(-) create mode 100644 app/Services/AboRetryPaymentService.php create mode 100644 dev/steuerberater/steuerberater-abgleich-status.csv create mode 100644 dev/steuerberater/steuerberater-abgleich-status.html create mode 100644 dev/steuerberater/steuerberater-abgleich-status.md create mode 100644 dev/steuerberater/steuerberater.csv create mode 100644 tests/Feature/AdminAboRetryPaymentTest.php diff --git a/app/Cron/UserMakeOrder.php b/app/Cron/UserMakeOrder.php index 819a9f5..6574d52 100644 --- a/app/Cron/UserMakeOrder.php +++ b/app/Cron/UserMakeOrder.php @@ -3,6 +3,7 @@ namespace App\Cron; use App\Http\Controllers\Pay\PayoneController; +use App\Models\PaymentTransaction; use App\Models\ShoppingOrder; use App\Models\ShoppingOrderItem; use App\Models\UserAbo; @@ -74,6 +75,7 @@ class UserMakeOrder $this->pay->setAboPayment($this->userAbo, $amount, 'EUR'); $this->pay->setPersonalData(); $response = $this->pay->onlyPaymentResponse(); + $this->recordPaymentTransaction($response); \Log::info('Response: '.json_encode($response)); // $response = $this->pay->ResponseData(true); @@ -86,6 +88,33 @@ class UserMakeOrder } } + private function recordPaymentTransaction(mixed $response): void + { + $shoppingPayment = $this->getShoppingPayment(); + if (! $shoppingPayment) { + return; + } + + $responseData = is_object($response) ? (array) $response : $response; + if (! is_array($responseData) || ! isset($responseData['status'])) { + return; + } + + PaymentTransaction::create([ + 'shopping_payment_id' => $shoppingPayment->id, + 'request' => 'authorization', + 'txid' => $responseData['txid'] ?? 0, + 'userid' => $responseData['userid'] ?? $this->userAbo->payone_userid ?? 0, + 'status' => $responseData['status'], + 'txaction' => $responseData['txaction'] ?? null, + 'transmitted_data' => $responseData, + 'errorcode' => $responseData['errorcode'] ?? null, + 'errormessage' => $responseData['errormessage'] ?? null, + 'customermessage' => $responseData['customermessage'] ?? null, + 'mode' => $shoppingPayment->mode, + ]); + } + public function getShoppingPayment() { Log::info('Rufe Zahlungsinformationen ab für UserAbo ID: '.$this->userAbo->id); diff --git a/app/Http/Controllers/Admin/AboController.php b/app/Http/Controllers/Admin/AboController.php index 08dead6..a8714ba 100644 --- a/app/Http/Controllers/Admin/AboController.php +++ b/app/Http/Controllers/Admin/AboController.php @@ -7,6 +7,7 @@ use App\Models\UserAbo; use App\Repositories\AboRepository; use App\Services\AboItemHistoryService; use App\Services\AboOrderCart; +use App\Services\AboRetryPaymentService; use App\Services\Shop; use Request; @@ -102,6 +103,16 @@ class AboController extends Controller return redirect(route('admin_abos_detail', [$id])); } + public function retryPayment($id, AboRetryPaymentService $retryPaymentService) + { + $user_abo = UserAbo::findOrFail($id); + $result = $retryPaymentService->retry($user_abo); + + \Session()->flash($result['success'] ? 'alert-success' : 'alert-error', $result['message']); + + return redirect(route('admin_abos_detail', [$id])); + } + public function datatable() { diff --git a/app/Http/Controllers/User/AboController.php b/app/Http/Controllers/User/AboController.php index 4e45edd..2ef531b 100644 --- a/app/Http/Controllers/User/AboController.php +++ b/app/Http/Controllers/User/AboController.php @@ -107,7 +107,8 @@ class AboController extends Controller $data = Request::all(); $user_abo = UserAbo::findOrFail($id); $this->checkPermissions($view, $user_abo); - $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $view); + $editView = \Auth::user()?->isAdmin() ? 'admin' : $view; + $isAddOnlyMode = AboHelper::isAddOnlyMode($user_abo, $editView); if (isset($data['action'])) { if ($data['action'] === 'abo_update_settings') { @@ -127,7 +128,7 @@ class AboController extends Controller $qtyBefore = $UserAboItem->qty; $UserAboItem->qty = $UserAboItem->qty + 1; $UserAboItem->save(); - AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $view); + AboItemHistoryService::logProductAdded($user_abo, $UserAboItem, $qtyBefore, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, @@ -136,7 +137,7 @@ class AboController extends Controller 'qty' => 1, 'status' => 1, ]); - AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $view); + AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } } @@ -156,7 +157,7 @@ class AboController extends Controller } $UserAboItem->qty = $qty; $UserAboItem->save(); - AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $view); + AboItemHistoryService::logQtyChanged($user_abo, $UserAboItem, $qtyBefore, $qty, $editView); } } } @@ -181,7 +182,7 @@ class AboController extends Controller $message = __('abo.need_basis_product'); } if (! $message) { - AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $view); + AboItemHistoryService::logProductRemoved($user_abo, $userAboItem, $editView); $userAboItem->delete(); $user_abo->refresh(); // Abo neu laden um die aktualisierten Items zu erhalten } @@ -193,7 +194,7 @@ class AboController extends Controller $UserAboItem->product_id = $data['comp_product_id']; $UserAboItem->save(); $UserAboItem->load('product'); - AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $view); + AboItemHistoryService::logCompProductChanged($user_abo, $UserAboItem, $oldProduct, $UserAboItem->product, $editView); } else { $newItem = UserAboItem::create([ 'user_abo_id' => $user_abo->id, @@ -202,7 +203,7 @@ class AboController extends Controller 'qty' => 1, 'status' => 1, ]); - AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $view); + AboItemHistoryService::logProductAdded($user_abo, $newItem, 0, $editView); } } diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index f716869..7a03d09 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -4,6 +4,7 @@ namespace App\Repositories; use App\Models\UserAbo; use App\Services\AboHelper; +use Carbon\Carbon; class AboRepository extends BaseRepository { @@ -27,8 +28,10 @@ class AboRepository extends BaseRepository if ($data['action'] === 'abo_update_settings') { if ($this->validate($data)) { $this->updateStatus($data); - $this->model->abo_interval = $data['abo_interval']; - $nextDate = $this->calculateNewNextDate($data['abo_interval']); + $this->model->abo_interval = (int) $data['abo_interval']; + $nextDate = $this->isAdminUpdate($data) + ? $this->calculateAdminNextDate($data) + : $this->calculateNewNextDate((int) $data['abo_interval']); $this->model->next_date = $nextDate; $this->model->save(); @@ -49,10 +52,12 @@ class AboRepository extends BaseRepository private function updateStatus($data) { + $isAdminUpdate = $this->isAdminUpdate($data); + // Handle cancellation if (isset($data['abo_cancel']) && $data['abo_cancel'] == 'true') { // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert/gekündigt werden - if ($this->model->next_date) { + if (! $isAdminUpdate && $this->model->next_date) { $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { \Session()->flash('alert-error', __('abo.error_cancel_locked', ['days' => $daysUntil])); @@ -72,7 +77,7 @@ class AboRepository extends BaseRepository $active = (isset($data['abo_is_active']) && $data['abo_is_active']) ? true : false; // Sperre: 3 Tage vor Ausführung kann nicht mehr pausiert werden - if ($this->model->active && ! $active && $this->model->next_date) { + if (! $isAdminUpdate && $this->model->active && ! $active && $this->model->next_date) { $daysUntil = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); if ($daysUntil >= 0 && $daysUntil < self::LOCK_DAYS_PAUSE_CANCEL) { \Session()->flash('alert-error', __('abo.error_pause_locked', ['days' => $daysUntil])); @@ -122,12 +127,28 @@ class AboRepository extends BaseRepository return false; } } - if (! in_array($data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays)) { + if (! isset($data['abo_interval']) || ! in_array((int) $data['abo_interval'], \App\Models\UserAbo::$aboDeliveryDays, true)) { \Session()->flash('alert-error', __('abo.error_abo_interval')); return false; } + if ($this->isAdminUpdate($data)) { + if (! isset($data['abo_next_month']) || ! in_array($data['abo_next_month'], $this->getAdminExecutionMonths(), true)) { + \Session()->flash('alert-error', __('abo.error_next_date')); + + return false; + } + + if ($this->calculateAdminNextDate($data)->startOfDay()->lt(now()->startOfDay())) { + \Session()->flash('alert-error', __('abo.error_next_date')); + + return false; + } + + return true; + } + // Sperre: 10 Tage vor nächster Ausführung keine Änderungen mehr (Pakete werden vorgepackt) if ($this->model->next_date) { $daysUntilExecution = (int) now()->diffInDays(\Carbon\Carbon::parse($this->model->next_date), false); @@ -170,4 +191,26 @@ class AboRepository extends BaseRepository return AboHelper::setNextDate($referenceDate, $aboInterval); } + + private function calculateAdminNextDate(array $data): Carbon + { + return Carbon::createFromFormat('Y-m-d', $data['abo_next_month'].'-01') + ->startOfMonth() + ->addDays(((int) $data['abo_interval']) - 1); + } + + /** + * @return array + */ + private function getAdminExecutionMonths(): array + { + return collect(range(0, 3)) + ->map(fn (int $offset): string => now()->copy()->startOfMonth()->addMonths($offset)->format('Y-m')) + ->all(); + } + + private function isAdminUpdate(array $data): bool + { + return ($data['view'] ?? null) === 'admin'; + } } diff --git a/app/Repositories/CheckoutRepository.php b/app/Repositories/CheckoutRepository.php index fa16c0e..9823d56 100644 --- a/app/Repositories/CheckoutRepository.php +++ b/app/Repositories/CheckoutRepository.php @@ -74,6 +74,7 @@ class CheckoutRepository extends BaseRepository 'subtotal_ws' => $ShoppingCollectOrder->price_total_net, 'tax' => $ShoppingCollectOrder->tax_total, 'tax_split' => $ShoppingCollectOrder->tax_split, + 'net_split' => $ShoppingCollectOrder->net_split, 'total_shipping' => Yard::instance($this->instance)->totalWithShipping(2, '.', ''), 'points' => round($ShoppingCollectOrder->points, 2), 'weight' => 0, diff --git a/app/Services/AboRetryPaymentService.php b/app/Services/AboRetryPaymentService.php new file mode 100644 index 0000000..1fbeb7e --- /dev/null +++ b/app/Services/AboRetryPaymentService.php @@ -0,0 +1,238 @@ +status !== 3) { + return [ + 'success' => false, + 'message' => __('abo.retry_only_hold'), + ]; + } + + if (! $userAbo->active) { + return [ + 'success' => false, + 'message' => __('abo.retry_only_active'), + ]; + } + + if ($this->alreadyPaidToday($userAbo)) { + return [ + 'success' => false, + 'message' => __('abo.retry_already_paid_today'), + ]; + } + + \Log::channel('abo_order')->info('AboRetryPaymentService: Starte erneuten Zahlungsversuch', [ + 'abo_id' => $userAbo->id, + 'email' => $userAbo->email, + 'payone_userid' => $userAbo->payone_userid, + ]); + + AboHelper::ensureUserAboItemsFromLatestOrder($userAbo); + + $shoppingOrder = null; + $paymentAttemptRecorded = false; + $userOrder = new UserMakeOrder($userAbo); + + try { + if (! $userOrder->createShoppingUser()) { + return [ + 'success' => false, + 'message' => __('abo.retry_error_shopping_user'), + ]; + } + + $shoppingOrder = $userOrder->makeShoppingOrder(); + if (! $shoppingOrder) { + return [ + 'success' => false, + 'message' => __('abo.retry_error_order'), + ]; + } + + $response = $this->normalizePaymentResponse($userOrder->makePayment()); + + if (($response['status'] ?? null) === 'APPROVED') { + $this->markAboSuccess($userAbo, $shoppingOrder); + $paymentAttemptRecorded = true; + + return [ + 'success' => true, + 'message' => __('abo.retry_success', ['order' => $shoppingOrder->id]), + 'order_id' => $shoppingOrder->id, + ]; + } + + $this->logPaymentError($userAbo, $shoppingOrder, $response); + $this->markAboError($userAbo, $shoppingOrder); + $paymentAttemptRecorded = true; + $this->sendPaymentErrorMail($userOrder, $shoppingOrder, $response); + + return [ + 'success' => false, + 'message' => __('abo.retry_failed', [ + 'error' => $this->formatPaymentError($response), + 'order' => $shoppingOrder->id, + ]), + 'order_id' => $shoppingOrder->id, + ]; + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Exception beim erneuten Zahlungsversuch', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder?->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + if ($shoppingOrder && ! $paymentAttemptRecorded) { + $this->markAboError($userAbo, $shoppingOrder); + } + + return [ + 'success' => false, + 'message' => __('abo.retry_exception', ['error' => $e->getMessage()]), + 'order_id' => $shoppingOrder?->id, + ]; + } + } + + private function alreadyPaidToday(UserAbo $userAbo): bool + { + return UserAboOrder::where('user_abo_id', $userAbo->id) + ->whereDate('created_at', now()->toDateString()) + ->where('paid', true) + ->exists(); + } + + /** + * @return array + */ + private function normalizePaymentResponse(mixed $response): array + { + if (is_object($response)) { + return (array) $response; + } + + return is_array($response) ? $response : []; + } + + private function markAboSuccess(UserAbo $userAbo, mixed $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder): void { + $userAbo->update([ + 'status' => 2, + 'active' => true, + 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), + 'last_date' => now(), + ]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 1, + 'paid' => true, + ]); + }); + + try { + IncentiveTracker::trackAboActivated($shoppingOrder); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Incentive-Tracking nach erfolgreichem Retry fehlgeschlagen', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function markAboError(UserAbo $userAbo, mixed $shoppingOrder): void + { + DB::transaction(function () use ($userAbo, $shoppingOrder): void { + $userAbo->update([ + 'status' => 3, + 'last_date' => now(), + ]); + + UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 3, + 'paid' => false, + ]); + }); + } + + /** + * @param array $response + */ + private function logPaymentError(UserAbo $userAbo, mixed $shoppingOrder, array $response): void + { + \Log::channel('abo_order')->error('AboRetryPaymentService: Zahlungsfehler beim erneuten Versuch', [ + 'abo_id' => $userAbo->id, + 'order_id' => $shoppingOrder->id, + 'response' => $response, + ]); + + MyLog::writeLog( + 'userabo', + 'error', + 'Error:AboRetryPaymentService::retry / makePayment Error', + $response + ); + } + + /** + * @param array $response + */ + private function sendPaymentErrorMail(UserMakeOrder $userOrder, mixed $shoppingOrder, array $response): void + { + $shoppingPayment = $userOrder->getShoppingPayment(); + if (! $shoppingPayment) { + return; + } + + try { + Payment::paymentStatusSendMail($shoppingOrder, $shoppingPayment, [ + 'mode' => $shoppingPayment->mode, + 'txaction' => 'error', + 'send_link' => false, + 'payment_error' => $response, + ]); + } catch (\Throwable $e) { + \Log::channel('abo_order')->error('AboRetryPaymentService: Fehlermail nach Zahlungsfehler konnte nicht gesendet werden', [ + 'order_id' => $shoppingOrder->id, + 'payment_id' => $shoppingPayment->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * @param array $response + */ + private function formatPaymentError(array $response): string + { + $errorCode = $response['errorcode'] ?? null; + $errorMessage = $response['errormessage'] ?? $response['customermessage'] ?? ($response['status'] ?? __('payment.unknown')); + + if ($errorCode) { + return '['.$errorCode.'] '.$errorMessage; + } + + return (string) $errorMessage; + } +} diff --git a/app/Services/DatevExportService.php b/app/Services/DatevExportService.php index f8e9590..c6db121 100644 --- a/app/Services/DatevExportService.php +++ b/app/Services/DatevExportService.php @@ -221,7 +221,7 @@ class DatevExportService $csvContent = $this->buildCsv($export); $filename = $this->generateFilename($month, $year); $storagePath = $export->getStoragePath(); - $fullPath = $storagePath . '/' . $filename; + $fullPath = $storagePath.'/'.$filename; Storage::disk(config('datev.storage_disk'))->makeDirectory($storagePath); Storage::disk(config('datev.storage_disk'))->put($fullPath, $csvContent); @@ -254,6 +254,7 @@ class DatevExportService 'shopping_order.country.country', 'shopping_order.shopping_user', 'shopping_order.auth_user.account', + 'shopping_order.shopping_collect_order', ]) ->where('month', $month) ->where('year', $year) @@ -289,13 +290,15 @@ class DatevExportService // Tax-Split vorhanden? -> Mehrere Zeilen pro Steuersatz if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { + $netSplit = $this->resolveNetSplit($order); + foreach ($order->tax_split as $taxRate => $taxAmount) { $taxRate = intval($taxRate); - $taxAmountFloat = $this->parseNumber($taxAmount); + $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax'); $netAmount = 0; - if ($order->net_split && isset($order->net_split[$taxRate])) { - $netAmount = $this->parseNumber($order->net_split[$taxRate]); + if ($netSplit && isset($netSplit[$taxRate])) { + $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net'); } $grossAmount = round($netAmount + $taxAmountFloat, 2); @@ -389,7 +392,7 @@ class DatevExportService // Steuerstatus des Beraters ermitteln $taxStatus = $this->determineCommissionTaxStatus($account); - $buSchluessel = config('datev.commission_tax_keys.' . $taxStatus, 9); + $buSchluessel = config('datev.commission_tax_keys.'.$taxStatus, 9); // USt-ID für Reverse Charge $euUstid = null; @@ -487,6 +490,7 @@ class DatevExportService 'shopping_order.country.country', 'shopping_order.shopping_user', 'shopping_order.auth_user.account', + 'shopping_order.shopping_collect_order', ]) ->where('month', $month) ->where('year', $year) @@ -500,7 +504,7 @@ class DatevExportService } $gegenkonto = $this->determineCounterAccountForOrder($order); - $buchungstext = 'STORNO ' . $this->buildBuchungstext($order); + $buchungstext = 'STORNO '.$this->buildBuchungstext($order); $buchungstext = mb_substr($buchungstext, 0, 60); $belegdatum = $this->parseBelegdatum($invoice); @@ -512,13 +516,15 @@ class DatevExportService $hasValidUstid = ! empty($euUstid); if ($order->tax_split && is_array($order->tax_split) && count($order->tax_split) > 0) { + $netSplit = $this->resolveNetSplit($order); + foreach ($order->tax_split as $taxRate => $taxAmount) { $taxRate = intval($taxRate); - $taxAmountFloat = $this->parseNumber($taxAmount); + $taxAmountFloat = $this->parseNumber($taxAmount, 'ek_tax'); $netAmount = 0; - if ($order->net_split && isset($order->net_split[$taxRate])) { - $netAmount = $this->parseNumber($order->net_split[$taxRate]); + if ($netSplit && isset($netSplit[$taxRate])) { + $netAmount = $this->parseNumber($netSplit[$taxRate], 'ek_net'); } $grossAmount = round($netAmount + $taxAmountFloat, 2); @@ -598,18 +604,18 @@ class DatevExportService } // Zeile 1: Header - $output .= $this->buildHeaderLine($export) . $lineEnding; + $output .= $this->buildHeaderLine($export).$lineEnding; // Zeile 2: Spaltenüberschriften - $output .= implode($delimiter, self::COLUMN_HEADERS) . $lineEnding; + $output .= implode($delimiter, self::COLUMN_HEADERS).$lineEnding; // Zeile 3+: Datenzeilen $lines = $export->lines()->orderBy('line_number')->get(); foreach ($lines as $line) { if ($line->row_csv) { - $output .= $line->row_csv . $lineEnding; + $output .= $line->row_csv.$lineEnding; } else { - $output .= $this->renderCsvRow($line->toArray()) . $lineEnding; + $output .= $this->renderCsvRow($line->toArray()).$lineEnding; } } @@ -722,7 +728,7 @@ class DatevExportService 'user_id' => $line['user_id'] ?? null, 'belegfeld1' => $line['belegfeld1'] ?? null, ]; - $lineRef = ($meta['source_type'] ?? '?') . ' #' . ($meta['source_id'] ?? '?'); + $lineRef = ($meta['source_type'] ?? '?').' #'.($meta['source_id'] ?? '?'); if (empty($line['belegdatum'])) { $errors[] = $this->buildValidationEntry("{$lineRef}: Belegdatum fehlt.", $meta); @@ -790,7 +796,7 @@ class DatevExportService // Gruppierte Zusammenfassung nach Konto + BU $grouped = $allLines->groupBy(function ($line) { - return $line['konto'] . '-' . $line['bu_schluessel'] . '-' . $line['soll_haben']; + return $line['konto'].'-'.$line['bu_schluessel'].'-'.$line['soll_haben']; })->map(function ($group, $key) { $first = $group->first(); @@ -869,7 +875,7 @@ class DatevExportService // Zusammengesetzter Key aus clearingtype + wallettype $key = $payment->clearingtype; if ($payment->wallettype) { - $key .= '_' . $payment->wallettype; + $key .= '_'.$payment->wallettype; } $map = config('datev.counteraccount_map', []); @@ -984,7 +990,7 @@ class DatevExportService { if ($order->shopping_user) { $name = trim( - ($order->shopping_user->billing_lastname ?? '') . ' ' . + ($order->shopping_user->billing_lastname ?? '').' '. ($order->shopping_user->billing_firstname ?? '') ); @@ -1005,7 +1011,7 @@ class DatevExportService $parts = []; if ($account) { - $name = trim(($account->last_name ?? '') . ' ' . ($account->first_name ?? '')); + $name = trim(($account->last_name ?? '').' '.($account->first_name ?? '')); if (! empty($name)) { $parts[] = $name; } @@ -1013,7 +1019,7 @@ class DatevExportService $userId = $user?->id ?? $creditUserId; if ($userId) { - $parts[] = 'Nr.' . $userId; + $parts[] = 'Nr.'.$userId; } $text = implode('; ', $parts); @@ -1038,20 +1044,60 @@ class DatevExportService return Carbon::create($invoice->year, $invoice->month, 1)->format('Y-m-d'); } + /** + * Ermittelt den Netto-Split. Historische Sammelrechnungen haben ihn teils nur + * an der ShoppingCollectOrder, nicht an der erzeugten ShoppingOrder. + */ + private function resolveNetSplit($order): ?array + { + if ($order->net_split && is_array($order->net_split)) { + return $order->net_split; + } + + $collectOrderNetSplit = $order->shopping_collect_order?->net_split; + + if ($collectOrderNetSplit && is_array($collectOrderNetSplit)) { + return $collectOrderNetSplit; + } + + return null; + } + /** * Parst einen formatierten Zahlenwert (z.B. "5.00" oder "5,00") zu float. * Behandelt sowohl einfache Werte als auch tax_split Arrays (homeparty). */ - private function parseNumber($value): float + private function parseNumber($value, ?string $preferredArrayKey = null): float { if (is_array($value)) { - // Homeparty tax_split Format: ['vk_tax' => '5.00', 'ek_tax' => '2.00'] - return floatval(str_replace(',', '.', $value['vk_tax'] ?? 0)); + $arrayKeys = $this->getSplitArrayKeys($preferredArrayKey); + + foreach ($arrayKeys as $arrayKey) { + if (array_key_exists($arrayKey, $value)) { + return $this->parseNumber($value[$arrayKey]); + } + } + + return 0.0; } return floatval(str_replace(',', '.', $value)); } + /** + * @return array + */ + private function getSplitArrayKeys(?string $preferredArrayKey): array + { + return match ($preferredArrayKey) { + 'ek_tax' => ['ek_tax', 'vk_tax', 'ek_net', 'vk_net'], + 'ek_net' => ['ek_net', 'vk_net', 'ek_tax', 'vk_tax'], + 'vk_tax' => ['vk_tax', 'ek_tax', 'vk_net', 'ek_net'], + 'vk_net' => ['vk_net', 'ek_net', 'vk_tax', 'ek_tax'], + default => ['vk_tax', 'vk_net', 'ek_tax', 'ek_net'], + }; + } + /** * Escaped ein CSV-Feld (Semikolon, Anführungszeichen, Newlines). */ @@ -1061,7 +1107,7 @@ class DatevExportService $value = str_replace('"', '""', $value); if (str_contains($value, ';') || str_contains($value, '"') || str_contains($value, "\n")) { - return '"' . $value . '"'; + return '"'.$value.'"'; } return $value; @@ -1074,6 +1120,6 @@ class DatevExportService { $monthPad = str_pad($month, 2, '0', STR_PAD_LEFT); - return "EXTF_Buchungsstapel_{$year}_{$monthPad}_" . date('YmdHis') . '.csv'; + return "EXTF_Buchungsstapel_{$year}_{$monthPad}_".date('YmdHis').'.csv'; } } diff --git a/dev/steuerberater/steuerberater-abgleich-status.csv b/dev/steuerberater/steuerberater-abgleich-status.csv new file mode 100644 index 0000000..f734ec0 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.csv @@ -0,0 +1,17 @@ +Bereich;Thema;Referenz;Anzahl;Befund;Einordnung;Status +Umsetzung;Sammelrechnungen/API;August 2025;14;"Export enthielt nur Steueranteil statt Netto+Steuer, weil net_split auf ShoppingOrder fehlte";"Technische Ursache identifiziert und behoben: net_split wird gespeichert, historischer Fallback auf ShoppingCollectOrder.net_split";Umgesetzt +Umsetzung;Homeparty;August 2025;3;"Export las falsche Split-Werte (vk_*) und wich stark vom Rechnungstotal ab";"Technische Ursache identifiziert und behoben: Homeparty liest ek_tax/ek_net";Umgesetzt +Abgleichstatus;Rueckmeldezeilen;steuerberater.csv;66;"Gesamtzahl Rueckmeldezeilen";"Basis fuer Abgleich DATEV vs. Steuerberaterliste";Info +Abgleichstatus;Fehlend im August-Export;steuerberater.csv vs. August-Export;30;"Belegnummer nicht in August-Export enthalten";"Davon 27 in DB als September 2025, 3 unter Rechnungsnummer nicht in DB gefunden";Teilweise geklaert +Abgleichstatus;Export = Betrag Test + 19%;steuerberater.csv vs. August-Export;24;"Muster: Betrag Test + 19% USt";"Hinweis auf Netto/Brutto-Missverstaendnis plus vorherige technische Split-Fehler";Geklaert +Abgleichstatus;#NV in Test aber Export vorhanden;steuerberater.csv vs. August-Export;5;"Rueckmeldung und Export widersprechen sich";"DB-basiert vorhanden, Bewertung nach Periode/Status statt #NV";Teilweise geklaert +Kategorie;Testdaten fehlerhaft: Betrag;steuerberater.csv;44;"Mix aus echten Technikfehlern und Perioden-/Datenbasisabweichungen";"Sammelrechnung/Homeparty behoben, Rest ueber Periode und Belegfluss klaeren";In Arbeit +Kategorie;Testdaten unvollstaendig;steuerberater.csv;10;"6 im August fehlend, 4 trotz #NV vorhanden";"Mehrere Faelle sind September oder abweichender Datenstand";In Arbeit +Kategorie;Testdaten fehlerhaft: Logik;steuerberater.csv;8;"3 August-Faelle 8400, 5 nicht im August";"Kein pauschaler Fehler: 8400 ohne verifizierte USt-ID plausibel, 8125 bei verifizierter USt-ID";Teilweise geklaert +Kategorie;Testdaten fehlerhaft: Storno;steuerberater.csv;3;"Vom Steuerberater als Storno markiert";"Fachlich fuer August kein DATEV-Storno, da damals keine Stornorechnungserstellung im System";Fachlich geklaert +Fachlich;USt-ID Verifikation pro Bestellung;Folgeticket;1;"Derzeit Validierung beim Eintragen, nicht bei jeder Bestellung";"Soll umgesetzt werden: pro Bestellung pruefen, bei ungueltig Hinweis + USt-Berechnung";Offen +Fachlich;Doppelzahlungen/Payone;Folgeticket;1;"DATEV-Export belegt keine echte Doppelabbuchung";"Pruefung ueber payment_transactions (txid/reference) + PAYONE Portal/API erforderlich";Offen +Logikfall;8125 korrekt;202537251;1;"ES-USt-ID vorhanden, reverse_charge=1, Export auf 8125/BU 1";"Entspricht Rechnungs-/Systemlogik zum Zeitpunkt";Geklaert +Logikfall;8400 plausibel;202536848|202537290|202537832;3;"AT-Faelle ohne verifizierte USt-ID, taxable_sales=2";"8400 aus Datenlage plausibel; fuer 8125 fehlt USt-ID-Grundlage";Geklaert +Periodenbefund;September statt August;"202538154|202538182|202538270|202538271|202538332|202538398|202538429|202538431|202538445|202538446|202538661|202538728|202538744|202538759|202538774|202538809|202538926|202539010|202539011|202539031|202539062|202539094|202539162|202539168|202539302|202539399|202539419";27;"In DB Periode 09/2025";"Kein August-Exportfehler";Geklaert +DB-Befund;Nicht unter Rechnungsnummer gefunden;202506145|202506147|202538333;3;"Nicht in DB auffindbar (unter genannter full_number)";"Separat klaeren: Datenstand, anderes Format oder extern erzeugte Rechnung";Offen diff --git a/dev/steuerberater/steuerberater-abgleich-status.html b/dev/steuerberater/steuerberater-abgleich-status.html new file mode 100644 index 0000000..5760e16 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.html @@ -0,0 +1,310 @@ + + + + + + Steuerberater-Abgleich DATEV - Status + + + +
+
+

Steuerberater-Abgleich DATEV (Stand nach Korrekturen)

+
+ Basis: steuerberater.csv vs. August-Export DATEV plus DB-Abgleich + (user_invoices, shopping_orders, datev_export_lines). +
+
+
+
66
+
Rueckmeldezeilen Steuerberater
+
+
+
30
+
Nicht im August-Export enthalten
+
+
+
27
+
Davon DB-Periode September 2025
+
+
+
3
+
Unter Rechnungsnummer nicht in DB
+
+
+
+ Auffaelliges Muster + 24 Rueckmeldefaelle entsprechen dem Schema Betrag Test + 19%. Neben Netto/Brutto-Missverstaendnis gab es + echte technische Split-Fehler bei Sammelrechnungen und Homeparty. +
+
+ Nach Umsetzung + Sammelrechnungen nutzen jetzt Netto+Steuer korrekt, Homeparty liest ek_tax/ek_net. + Kritische Beispielbelege laufen auf die Rechnungstotals. +
+
+ +
+

Umsetzung und offene Themen

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ThemaIst-Zustand / UrsacheAenderung / EntscheidungStatus
Sammelrechnungen/APIExport enthielt nur Steueranteil, weil net_split auf ShoppingOrder fehlte.net_split wird gespeichert; historischer Fallback auf ShoppingCollectOrder.net_split.Umgesetzt
HomepartyExport las vk_*; Rechnungstotal basiert auf ek_*.DATEV-Service nutzt fuer Homeparty nun ek_tax/ek_net.Umgesetzt
USt-ID pro BestellungValidierung erfolgt aktuell beim Eintragen, nicht bei jeder Bestellung.Folgeticket: pro Bestellung pruefen; bei ungueltig Warnung + USt-Berechnung.Offen
Storno in AugustVom Steuerberater als Storno markiert, aber keine Stornorechnungslogik aktiv gewesen.Fuer August kein DATEV-Storno ableiten, solange keine echte Stornorechnung existiert.Fachlich geklaert
Doppelzahlung / PayoneDATEV-Export belegt keine echte Doppelabbuchung.Abgleich ueber payment_transactions plus PAYONE Portal/API erforderlich.Offen
+
+ +
+

Logik-Hinweise 8125 vs. 8400

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RechnungExportkontoSystembefundEinordnung
202536848 / 202537290 / 2025378328400AT-Faelle ohne verifizierte USt-ID, taxable_sales=28400 ist aus Systemdaten plausibel; fuer 8125 fehlt USt-ID-Grundlage.
2025372518125 / BU 1ES-USt-ID vorhanden, reverse_charge=1Export folgt Rechnungsstand und ist fachlich konsistent.
202538445 / 202538446 / 202538774 / 202539011 / 202539399Nicht in August-DateiDB-Periode September 2025Kein August-Exportfehler; separat gegen September-Export pruefen.
+
+ +
+

Fehlende Belege im August-Export

+ + + + + + + + + + + + + + + + + + + + +
RechnungDB-BefundEinordnung
202506145 / 202506147 / 202538333Nicht in DB gefundenSeparat klaeren: Datenstand, Rechnungsformat oder extern erzeugte Rechnung.
+ 202538154, 202538182, 202538270, 202538271, 202538332, 202538398, 202538429, 202538431, 202538445, + 202538446, 202538661, 202538728, 202538744, 202538759, 202538774, 202538809, 202538926, 202539010, + 202539011, 202539031, 202539062, 202539094, 202539162, 202539168, 202539302, 202539399, 202539419 + DB-Periode September 2025Kein August-Exportfehler.
+
+
+ + diff --git a/dev/steuerberater/steuerberater-abgleich-status.md b/dev/steuerberater/steuerberater-abgleich-status.md new file mode 100644 index 0000000..f7011e9 --- /dev/null +++ b/dev/steuerberater/steuerberater-abgleich-status.md @@ -0,0 +1,58 @@ +# Steuerberater-Abgleich DATEV (Stand nach Korrekturen) + +## Kontext +- Basisdateien: + - `dev/steuerberater/steuerberater.csv` (Rueckmeldung Steuerberater) + - `storage/app/datev/2025/08/EXTF_Buchungsstapel_2025_08_20260312100928.csv` (Systemexport August) +- Zusaetzlich geprueft: + - DB-Daten aus `user_invoices`, `shopping_orders`, `datev_export_lines` + - Sonderfaelle Homeparty und Sammelrechnungen/API + +## Was wurde technisch gefixt +1. Sammelrechnungen/API: + - Problem: In vielen Faellen wurde nur der Steueranteil exportiert. + - Ursache: `net_split` fehlte auf `ShoppingOrder` (historisch), DATEV-Bildung hatte dadurch keinen Nettoanteil. + - Fix: `net_split` wird bei Sammelrechnungen gespeichert; DATEV nutzt historisch `shopping_collect_order.net_split` als Fallback. + +2. Homeparty: + - Problem: Exportbetrag wich deutlich vom Rechnungstotal ab. + - Ursache: Homeparty-Splits wurden aus `vk_*` gelesen, Rechnungssicht basiert aber auf `ek_*`. + - Fix: DATEV nutzt fuer diese Arrays `ek_tax` und `ek_net`. + +## Ergebnis nach Korrektur (Smoke-Test) +- Beispielbelege jetzt korrekt zum Rechnungstotal: + - `202536737` -> `634,88` + - `202536738` -> `806,14` + - `202537289` -> `228,86` + - `202537883` -> `426,63` + - `202537907` -> `609,87` + +## Einordnung der offenen Punkte +1. Logik 8125 vs. 8400: + - Kein pauschaler Exportfehler. + - Ohne verifizierte USt-ID bleibt `8400` plausibel. + - Mit verifizierter USt-ID/Reverse-Charge folgt Export der Rechnung und bucht auf `8125`. + +2. Storno-Hinweise: + - Fuer August fachlich kein DATEV-Storno ableitbar. + - Grund: Zu diesem Zeitpunkt waren im System noch keine echten Stornorechnungen aktiv; teils nur Liefer-/Bestellstorno oder Rechnungsvermerk. + +3. Fehlende Rechnungen: + - Viele Rueckmeldebelege liegen in der DB in `09/2025` und fehlen deshalb korrekt im August-Export. + - Drei Rechnungsnummern wurden unter der angegebenen `full_number` nicht gefunden: + - `202506145` + - `202506147` + - `202538333` + +4. USt-ID Folgeticket (offen): + - Aktuell: Validierung beim Eintragen. + - Soll: Bei jeder Bestellung neu pruefen; wenn ungueltig, Hinweis + USt-Berechnung. + +5. Doppelzahlungen/Payone (offen): + - DATEV-Export allein reicht nicht als Nachweis. + - Erforderlich: Abgleich `payment_transactions` (`txid`, `reference`) plus PAYONE Portal/API. + +## CSV fuer Steuerberater +- Strukturierte Fassung liegt in: + - `dev/steuerberater/steuerberater-abgleich-status.csv` + diff --git a/dev/steuerberater/steuerberater.csv b/dev/steuerberater/steuerberater.csv new file mode 100644 index 0000000..b116f0a --- /dev/null +++ b/dev/steuerberater/steuerberater.csv @@ -0,0 +1,67 @@ +Rechnung;Betrag Test;Betrag gebucht;Delta_1;Delta;Fehler;Kategorie;Bemerkung +202506145;#NV;265,8;-265,80;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202506147;#NV;115,5;-115,50;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536737;85,1;533,51;-448,41;-448,41;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202536738;108,09;677,43;-569,34;-569,34;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,08 EUR) +202536817;230,28;1434,71;-1.204,43;-1.204,43;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202536821;#NV;444,54;-444,54;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536841;#NV;93,43;-93,43;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202536843;104,13;652,71;-548,58;-548,58;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,10 EUR) +202536848;233,55;#NV;233,55;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202536925;52,69;330,27;-277,58;-277,58;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202536968;167,67;1044,69;-877,02;-877,02;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202536978;95,29;0;95,29;95,29;;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Doppeltbestellung +202537144;146,97;0;146,97;146,97;;Testdaten fehlerhaft: Storno;Rg. Wurde storniert wegen Falschbestellung +202537208;122,52;101,68;20,84;20,84;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537251;#NV;42,3;-42,30;Fehler;;Fachliche Klärung;von 8125 auf 8400 gebucht, obwohl spanische USt-IdNr. Vorhanden, diese ist wohl ungültig lt. Alex Dachs +202537289;39,34;163,03;-123,69;-123,69;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537290;226,44;#NV;226,44;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537317;136,16;853,15;-716,99;-716,99;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202537450;72,57;454,54;-381,97;-381,97;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537460;#NV;221,09;-221,09;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537513;101,68;22,27;79,41;79,41;;Testdaten fehlerhaft: Storno;Rg. Wurde teilweise storniert +202537532;172,55;1075,04;-902,49;-902,49;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537543;#NV;791,8;-791,80;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202537564;59,61;373,5;-313,89;-313,89;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537567;18,32;86,72;-68,40;-68,40;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537572;177,06;90,34;86,72;86,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537607;19,85;124,39;-104,54;-104,54;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537698;76,73;497,54;-420,81;-420,81;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht (abgesehen von 3,22 EUR) +202537741;86,08;539,2;-453,12;-453,12;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202537808;86,22;86,13;0,09;0,09;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR) +202537832;129,35;#NV;129,35;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202537840;86,43;541,73;-455,30;-455,30;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202537883;40,39;162,69;-122,30;-122,30;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202537907;108,4;465,32;-356,92;-356,92;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202537926;57,63;361,08;-303,45;-303,45;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202537952;18,17;113,88;-95,71;-95,71;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202538011;259,12;239,65;19,47;19,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538039;60,44;378,76;-318,32;-318,32;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538154;72,85;456,49;-383,64;-383,64;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,04 EUR) +202538182;231,04;134,54;96,50;96,50;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538270;53,14;333,08;-279,94;-279,94;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202538271;45,7;286,39;-240,69;-240,69;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202538332;40,84;4,12;36,72;36,72;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202538333;#NV;36,72;-36,72;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht (Anderes Rechnungsformat) +202538398;#NV;142,57;-142,57;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538429;73,19;458,66;-385,47;-385,47;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538431;84,52;529,69;-445,17;-445,17;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202538445;227,39;#NV;227,39;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538446;358,96;#NV;358,96;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202538661;116,85;732,15;-615,30;-615,30;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202538728;58,18;251,86;-193,68;-193,68;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538744;#NV;87,9;-87,90;Fehler;Test;Testdaten unvollständig;Testdaten unvollständig, Rechnung liegt uns vor und wurde korrekt verbucht +202538759;121,09;758,77;-637,68;-637,68;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202538774;87,9;#NV;87,90;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202538809;72,13;452,04;-379,91;-379,91;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) +202538926;102,96;645,21;-542,25;-542,25;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,07 EUR) +202539010;27,63;118,16;-90,53;-90,53;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202539011;239,65;#NV;239,65;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 (Beleg nicht angehängt) +202539031;141,52;886,85;-745,33;-745,33;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,09 EUR) +202539062;46,05;197,6;-151,55;-151,55;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,02 EUR) +202539094;94,31;591,02;-496,71;-496,71;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,06 EUR) +202539162;37,13;160,82;-123,69;-123,69;Test;Testdaten fehlerhaft: Betrag;Testdaten fehlerhaft, Rechnung liegt uns vor und wurde korrekt verbucht +202539168;37,95;237,75;-199,80;-199,80;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,01 EUR) +202539302;84,31;528,24;-443,93;-443,93;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,03 EUR) +202539399;248;#NV;248,00;Fehler;Test;Testdaten fehlerhaft: Logik;Rechnung korrekterweise auf 8125 gebucht nicht 8400 +202539419;61,01;382,37;-321,36;-321,36;Test;Testdaten fehlerhaft: Betrag;gebuchte Rechnung stimmt mit Beleg überein (abgesehen von netto 0,05 EUR) diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 19628f1..8ee9e48 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'Wenn das Abonnement nicht aktiv ist, erfolgt keine automatische Ausführung.', 'abo_copy_next_date' => 'Der nächste Ausführungstermin kann frühesten auf den Folgetag festgelegt werden.', 'abo_copy_abo_interval' => 'Die Anpassung des Abonnement-Liefertags wirkt sich auf den kommenden Ausführungstermin aus, wenn das Abonnement aktiv ist.', + 'admin_abo_copy_next_date' => 'Admins können den nächsten Ausführungstermin direkt über Monat und Liefertag festlegen.', 'error_abo_interval' => 'Das Abo Interval nicht korrekt', 'error_abo_interval_in_the_past' => 'Das Abo wurde diesen Monat noch nicht ausgeführt. Eine Änderung auf einen vergangenen Tag würde den aktuellen Monat überspringen.', 'warning_next_date_soon' => 'Hinweis: Die nächste Abo-Ausführung ist bereits in :days Tagen (:date).', @@ -110,6 +111,18 @@ return [ 'abo_error_basis_product' => 'Fehler: Bitte wählen Sie mindestens ein Basis-Produkt aus.', 'cancel_abo' => 'Abo kündigen', 'confirm_cancel' => 'Möchten Sie das Abo wirklich kündigen?', + 'retry_payment' => 'Zahlung erneut ausführen', + 'retry_payment_confirm_title' => 'Bitte bewusst bestätigen', + 'retry_payment_confirm_copy' => 'Der Zahlungsversuch wird sofort gestartet und kann eine PayPal- oder Kreditkartenbuchung auslösen. Bitte nur ausführen, wenn die Ursache geprüft wurde.', + 'retry_payment_confirm_button' => 'Zahlung jetzt erneut versuchen', + 'retry_only_hold' => 'Der erneute Zahlungsversuch ist nur für angehaltene Abos möglich.', + 'retry_only_active' => 'Der erneute Zahlungsversuch ist nur für aktive Abos möglich.', + 'retry_already_paid_today' => 'Für dieses Abo wurde heute bereits eine erfolgreiche Zahlung gespeichert.', + 'retry_error_shopping_user' => 'Der Shopping-User konnte für den erneuten Zahlungsversuch nicht erstellt werden.', + 'retry_error_order' => 'Die Bestellung konnte für den erneuten Zahlungsversuch nicht erstellt werden.', + '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', 'team_subscriptions' => 'Team Abos', 'team_customer_abos' => 'Team Kunden-Abos', 'chart_monthly_abos' => 'Abos pro Monat', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index 228b196..69e9067 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'If the subscription is not active, it will not be executed automatically', 'abo_copy_next_date' => 'The next execution date can be set to the following day at the earliest', 'abo_copy_abo_interval' => 'Adjusting the subscription delivery day affects the upcoming execution date when the subscription is active.', + 'admin_abo_copy_next_date' => 'Admins can set the next execution date directly using month and delivery day.', 'error_abo_interval' => 'The subscription interval is not correct', 'error_next_date' => 'The date for the next execution is not correct', 'checkout_mail_abo_hl' => 'Your subscription / regular delivery', @@ -110,6 +111,18 @@ return [ 'error_pause_locked' => 'The subscription can no longer be paused. The next execution is in :days days. Pausing must be done at least 3 days in advance.', 'cancel_abo' => 'Cancel subscription', 'confirm_cancel' => 'Do you really want to cancel the subscription?', + 'retry_payment' => 'Retry payment', + 'retry_payment_confirm_title' => 'Please confirm deliberately', + 'retry_payment_confirm_copy' => 'The payment attempt will start immediately and can trigger a PayPal or credit card charge. Please run this only after checking the cause.', + 'retry_payment_confirm_button' => 'Retry payment now', + 'retry_only_hold' => 'The payment retry is only available for held subscriptions.', + 'retry_only_active' => 'The payment retry is only available for active subscriptions.', + 'retry_already_paid_today' => 'A successful payment has already been recorded for this subscription today.', + 'retry_error_shopping_user' => 'The shopping user could not be created for the payment retry.', + 'retry_error_order' => 'The order could not be created for the payment retry.', + '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', 'back' => 'back', 'team_subscriptions' => 'Team subscriptions', 'team_customer_abos' => 'Team Customer Subscriptions', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index f2f9759..c886ed5 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -32,6 +32,7 @@ return [ 'abo_copy_active' => 'Si la suscripción no está activa, no se ejecutará automáticamente', 'abo_copy_next_date' => 'La siguiente fecha de ejecución puede establecerse como muy pronto al día siguiente', 'abo_copy_abo_interval' => 'El ajuste del día de entrega de la suscripción afecta a la próxima fecha de ejecución cuando la suscripción está activa.', + 'admin_abo_copy_next_date' => 'Los administradores pueden establecer la próxima fecha de ejecución directamente mediante mes y día de entrega.', 'error_abo_interval' => 'El intervalo de suscripción no es correcto', 'error_next_date' => 'La fecha de la siguiente ejecución no es correcta', 'checkout_mail_abo_hl' => 'Su suscripción / entrega regular', @@ -110,6 +111,18 @@ return [ 'error_pause_locked' => 'La suscripción ya no puede pausarse. La próxima ejecución es en :days días. La pausa debe realizarse al menos 3 días antes.', 'cancel_abo' => 'Cancelar suscripción', 'confirm_cancel' => '¿Realmente desea cancelar la suscripción?', + 'retry_payment' => 'Reintentar pago', + 'retry_payment_confirm_title' => 'Confirme conscientemente', + 'retry_payment_confirm_copy' => 'El intento de pago comenzará inmediatamente y puede activar un cargo de PayPal o tarjeta de crédito. Ejecútelo solo después de comprobar la causa.', + 'retry_payment_confirm_button' => 'Reintentar pago ahora', + 'retry_only_hold' => 'El reintento de pago solo está disponible para suscripciones en pausa.', + 'retry_only_active' => 'El reintento de pago solo está disponible para suscripciones activas.', + 'retry_already_paid_today' => 'Ya se ha registrado hoy un pago correcto para esta suscripción.', + 'retry_error_shopping_user' => 'No se pudo crear el usuario de compra para el reintento de pago.', + 'retry_error_order' => 'No se pudo crear el pedido para el reintento de pago.', + 'retry_success' => 'El reintento de pago se realizó correctamente. Se creó el pedido #:order.', + 'retry_failed' => 'El reintento de pago falló. Se creó el pedido #:order. Error: :error', + 'retry_exception' => 'No se pudo completar el reintento de pago: :error', 'back' => 'atrás', 'team_subscriptions' => 'Suscripciones de equipo', 'team_customer_abos' => 'Suscripciones de clientes del equipo', diff --git a/resources/views/admin/abo/_detail.blade.php b/resources/views/admin/abo/_detail.blade.php index cdec861..c4a74b4 100644 --- a/resources/views/admin/abo/_detail.blade.php +++ b/resources/views/admin/abo/_detail.blade.php @@ -1,111 +1,120 @@ - - -
-
-
-
{{ __('tables.start_date') }}
- {{ $user_abo->start_date }} -
-
-
{{ __('tables.next_date') }}
- {{ $user_abo->next_date }} -
-
-
{{ __('tables.abo_delivery_day') }}
- {{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }} -
-
-
{{ __('tables.last_date') }}
- {{ $user_abo->last_date }} -
- -
+ +
+
+
+
{{ __('tables.start_date') }}
+ {{ $user_abo->start_date }}
-
-
-
-
-
{{ __('tables.status') }} / {{ __('tables.active') }}
- {!! $user_abo->getStatusFormated() !!}  {!! get_active_badge($user_abo->active) !!} -
-
-
{{ __('tables.abo_delivery') }}
- {{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }} - -
- -
-
{{ __('tables.payment') }}
- {{ $user_abo->getPaymentType() }} -
-
-
{{ __('tables.amount') }}
- {{ $user_abo->getFormattedAmount() }} € -
-
+
+
{{ __('tables.next_date') }}
+ {{ $user_abo->next_date }} +
+
+
{{ __('tables.abo_delivery_day') }}
+ {{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }} +
+
+
{{ __('tables.last_date') }}
+ {{ $user_abo->last_date }}
-
- @if($isAdmin) -
-
-
- -
- -
- @if(isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0) -
{{ __('tables.adviser') }}
- {!! ''.$user_abo->shopping_user->member->getFullName().'' !!} - @endif -
+
+
+
+
+
+
+
{{ __('tables.status') }} / {{ __('tables.active') }}
+ {!! $user_abo->getStatusFormated() !!}  {!! get_active_badge($user_abo->active) !!} + @if ($user_abo->status === 3 && $user_abo->active) + + @endif +
+
+
{{ __('tables.abo_delivery') }}
+ {{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }} -
-
{{ __('tables.is_for') }}
- {!! $user_abo->getIsForFormated() !!} - @if($user_abo->is_for === 'me') - +
- @endif +
+
{{ __('tables.payment') }}
+ {{ $user_abo->getPaymentType() }} +
+
+
{{ __('tables.amount') }}
+ {{ $user_abo->getFormattedAmount() }} € +
+
+
+ +
+@if ($isAdmin) +
+
+
+ - @if($user_abo->is_for === 'ot') - - @endif -
- -
- @else -
-
-
- @if(App\Services\AboHelper::canEditAbo($user_abo, $view)) - - @endif -
-
- {!! $user_abo->getIsForFormated() !!} - @if($user_abo->is_for === 'me') - - - @endif - - @if($user_abo->is_for === 'ot') - - @endif -
-
+
+ @if (isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0) +
{{ __('tables.adviser') }}
+ {!! '' . + $user_abo->shopping_user->member->getFullName() . + '' !!} + @endif
- @endif +
+
{{ __('tables.is_for') }}
+ {!! $user_abo->getIsForFormated() !!} + @if ($user_abo->is_for === 'me') + + @endif - \ No newline at end of file + @if ($user_abo->is_for === 'ot') + + @endif +
+ +
+
+@else +
+
+
+ @if (App\Services\AboHelper::canEditAbo($user_abo, $view)) + + @endif +
+ +
+ {!! $user_abo->getIsForFormated() !!} + @if ($user_abo->is_for === 'me') + + @endif + + @if ($user_abo->is_for === 'ot') + + @endif +
+
+
+@endif diff --git a/resources/views/admin/abo/_executions.blade.php b/resources/views/admin/abo/_executions.blade.php index 975874b..0f2807a 100644 --- a/resources/views/admin/abo/_executions.blade.php +++ b/resources/views/admin/abo/_executions.blade.php @@ -41,9 +41,18 @@ {{__('tables.rf_no')}} + @php + $executionColspan = (!isset($only_show_products) || !$only_show_products) ? 13 : 12; + @endphp @if($user_abo->user_abo_orders) @foreach($user_abo->user_abo_orders()->orderBy('id', 'desc')->get() as $user_abo_order) @if($user_abo_order->shopping_order) + @php + $paymentErrorTransaction = $user_abo_order->shopping_order->getLastShoppingPaymentTransaction(); + $hasPaymentError = ! $user_abo_order->paid + && $paymentErrorTransaction + && ($paymentErrorTransaction->errorcode || $paymentErrorTransaction->errormessage || $paymentErrorTransaction->customermessage); + @endphp @if($isAdmin) @@ -113,6 +122,25 @@ {{ $user_abo_order->shopping_order->getLastShoppingPayment('reference') }} + @if($hasPaymentError) + + +
+ {{ __('payment.payment_error') }}: + @if($paymentErrorTransaction->errorcode) + {{ $paymentErrorTransaction->errorcode }} + @endif + {{ $paymentErrorTransaction->errormessage ?: $paymentErrorTransaction->customermessage }} + @if($paymentErrorTransaction->error_description) + ({{ $paymentErrorTransaction->error_description }}) + @endif +
+ {{ $paymentErrorTransaction->created_at->format('d.m.Y H:i') }} +
+
+ + + @endif @endif @endforeach @endif diff --git a/resources/views/admin/abo/detail.blade.php b/resources/views/admin/abo/detail.blade.php index 35776f5..e08ef66 100644 --- a/resources/views/admin/abo/detail.blade.php +++ b/resources/views/admin/abo/detail.blade.php @@ -25,6 +25,15 @@
@endif + @if (Session::has('alert-success')) +
+
+
    +
  • {{ Session::get('alert-success') }}
  • +
+
+
+ @endif
@include('admin.abo._detail')
@@ -39,10 +48,11 @@ 'action' => route('user_abos_update', [$view, $user_abo->id]), 'class' => 'form-horizontal', 'id' => 'cart-order-form', + 'data-add-only-mode' => '0', ]) !!}
- @include('admin.abo._order_abo') + @include('admin.abo._order_abo', ['add_only_mode' => false])
@if ($comp_products && Yard::instance('shopping')->getNumComp() > 0) @@ -109,6 +119,51 @@
+ + @if ($user_abo->status === 3 && $user_abo->active) + + @endif @endsection @section('scripts') diff --git a/resources/views/admin/abo/modal_abo_update.blade.php b/resources/views/admin/abo/modal_abo_update.blade.php index be400e2..e3dce06 100644 --- a/resources/views/admin/abo/modal_abo_update.blade.php +++ b/resources/views/admin/abo/modal_abo_update.blade.php @@ -23,8 +23,34 @@ {!! HTMLHelper::getAboDeliveryOptions($user_abo->abo_interval) !!} + @if($data['view'] === 'admin') + @php + $selectedNextMonth = $user_abo->getRawOriginal('next_date') + ? \Carbon\Carbon::parse($user_abo->getRawOriginal('next_date'))->format('Y-m') + : now()->format('Y-m'); + @endphp +
+ + +
+ @endif
- {{ __('abo.abo_copy_abo_interval') }} + + @if($data['view'] === 'admin') + {{ __('abo.admin_abo_copy_next_date') }} + @else + {{ __('abo.abo_copy_abo_interval') }} + @endif +
diff --git a/routes/domains/crm.php b/routes/domains/crm.php index e434c8a..4e495bf 100644 --- a/routes/domains/crm.php +++ b/routes/domains/crm.php @@ -345,6 +345,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car Route::get('/admin/abos/detail/{id}', 'Admin\AboController@detail')->name('admin_abos_detail'); Route::post('/admin/abos/update/{id}', 'Admin\AboController@update')->name('admin_abos_update'); Route::post('/admin/abos/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::get('/admin/abos/datatable', 'Admin\AboController@datatable')->name('admin_abos_datatable'); // incentives diff --git a/tests/Feature/AboRepositoryDateChangeTest.php b/tests/Feature/AboRepositoryDateChangeTest.php index a44885f..6c890da 100644 --- a/tests/Feature/AboRepositoryDateChangeTest.php +++ b/tests/Feature/AboRepositoryDateChangeTest.php @@ -1,15 +1,23 @@ repository = new AboRepository; }); +afterEach(function () { + Carbon::setTestNow(); +}); + /** * Szenario: Heute = 15. März, next_date = 5. April (März-Ausführung bereits erfolgt). * Änderung auf Tag 20 → neues Datum soll der 20. April sein, NICHT der 20. März. @@ -86,6 +94,54 @@ it('verwendet heute als Referenz wenn kein next_date gesetzt ist', function () { Carbon::setTestNow(); }); +it('erlaubt Admins den nächsten Ausführungstermin ohne User-Sperrfrist direkt zu setzen', function () { + Carbon::setTestNow('2026-03-19 12:00:00'); + + $abo = createRepositoryTestAbo([ + 'abo_interval' => 5, + 'next_date' => '2026-03-20', + ]); + + $this->repository->setModel($abo); + + $result = $this->repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => 'admin', + 'abo_interval' => 20, + 'abo_next_month' => '2026-03', + 'abo_is_active' => 'true', + ]); + + expect($result)->toBeTrue(); + expect($abo->refresh()->abo_interval)->toBe(20); + expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); +}); + +it('behält die User-Sperrfrist für normale Abo-Änderungen bei', function () { + Carbon::setTestNow('2026-03-19 12:00:00'); + + $abo = createRepositoryTestAbo([ + 'abo_interval' => 5, + 'next_date' => '2026-03-20', + ]); + + $this->actingAs($abo->user); + $this->repository->setModel($abo); + + $result = $this->repository->update([ + 'action' => 'abo_update_settings', + 'id' => $abo->id, + 'view' => 'me', + 'abo_interval' => 20, + 'abo_is_active' => 'true', + ]); + + expect($result)->toBeFalse(); + expect($abo->refresh()->abo_interval)->toBe(5); + expect($abo->getRawOriginal('next_date'))->toBe('2026-03-20'); +}); + /** * Hilfsfunktion zum Aufruf privater Methoden. * @@ -98,3 +154,53 @@ function invadePrivateMethod(object $object, string $methodName, array $args = [ return $reflection->invoke($object, ...$args); } + +/** + * @param array $overrides + */ +function createRepositoryTestAbo(array $overrides = []): UserAbo +{ + $country = Country::create([ + 'code' => 'DE', + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Deutschland', + ]); + + $user = User::forceCreate([ + 'email' => 'abo-repository-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'admin' => 0, + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => $user->email, + 'is_for' => 'me', + 'is_from' => 'user_order', + ]); + + return UserAbo::create(array_merge([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => $shoppingUser->id, + 'is_for' => 'me', + 'email' => $user->email, + 'payone_userid' => random_int(100000, 999999), + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + 'start_date' => '2026-01-05', + 'last_date' => '2026-02-05', + 'next_date' => '2026-03-05', + ], $overrides)); +} diff --git a/tests/Feature/AdminAboRetryPaymentTest.php b/tests/Feature/AdminAboRetryPaymentTest.php new file mode 100644 index 0000000..3a85787 --- /dev/null +++ b/tests/Feature/AdminAboRetryPaymentTest.php @@ -0,0 +1,207 @@ + $fixture['userAbo'], + 'isAdmin' => false, + 'only_show_products' => true, + ])->render(); + + expect($html) + ->toContain('Zahlung fehlgeschlagen') + ->toContain('923') + ->toContain('Keine Deckung'); +}); + +it('blockiert erneute Zahlungsversuche für nicht angehaltene Abos vor dem Payment-Aufruf', function () { + $service = new AboRetryPaymentService; + $userAbo = new UserAbo([ + 'status' => 2, + 'active' => true, + ]); + + $result = $service->retry($userAbo); + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe(__('abo.retry_only_hold')); +}); + +it('speichert PAYONE-Fehler aus dem automatischen Abo-Lauf als PaymentTransaction', function () { + $fixture = createAdminAboRetryFixture(); + $shoppingPayment = $fixture['userAboOrder']->shopping_order->shopping_payments()->firstOrFail(); + $userMakeOrder = new UserMakeOrder($fixture['userAbo']); + + $payProperty = new ReflectionProperty($userMakeOrder, 'pay'); + $payProperty->setAccessible(true); + $payProperty->setValue($userMakeOrder, new class($shoppingPayment) + { + public function __construct(private ShoppingPayment $shoppingPayment) {} + + public function getShoppingPayment(): ShoppingPayment + { + return $this->shoppingPayment; + } + }); + + $recordMethod = new ReflectionMethod($userMakeOrder, 'recordPaymentTransaction'); + $recordMethod->setAccessible(true); + $recordMethod->invoke($userMakeOrder, [ + 'status' => 'ERROR', + 'errorcode' => 130, + 'errormessage' => 'Limit überschritten', + 'customermessage' => 'Die Zahlung wurde abgelehnt.', + ]); + + $paymentTransaction = $shoppingPayment->payment_transactions()->latest('id')->firstOrFail(); + + expect($paymentTransaction->status)->toBe('ERROR'); + expect($paymentTransaction->errorcode)->toBe(130); + expect($paymentTransaction->errormessage)->toBe('Limit überschritten'); +}); + +it('setzt die Mindestlaufzeit-Sperre fuer Admin-Bearbeitung ausser Kraft', function () { + $fixture = createAdminAboRetryFixture(); + + expect(AboHelper::isAddOnlyMode($fixture['userAbo'], 'admin'))->toBeFalse(); +}); + +/** + * @return array{userAbo: UserAbo, userAboOrder: UserAboOrder} + */ +function createAdminAboRetryFixture(): array +{ + $country = Country::create([ + 'code' => 'DE', + 'phone' => '49', + 'en' => 'Germany', + 'de' => 'Deutschland', + 'es' => 'Alemania', + 'fr' => 'Allemagne', + 'it' => 'Germania', + 'ru' => 'Deutschland', + ]); + + $shipping = Shipping::create([ + 'name' => 'Standard', + 'active' => true, + ]); + + $shippingCountry = ShippingCountry::create([ + 'shipping_id' => $shipping->id, + 'country_id' => $country->id, + ]); + + $user = User::forceCreate([ + 'email' => 'admin-abo-retry-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + ]); + + $userShop = UserShop::create([ + 'user_id' => $user->id, + 'name' => 'TS'.substr(uniqid('', true), 0, 8), + 'slug' => 'ts-'.uniqid(), + 'active' => true, + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'billing_email' => $user->email, + 'shipping_email' => $user->email, + 'shipping_firstname' => 'Max', + 'shipping_lastname' => 'Muster', + 'is_for' => 'me', + 'is_from' => 'user_order', + ]); + + $userAbo = UserAbo::create([ + 'user_id' => $user->id, + 'member_id' => $user->id, + 'shopping_user_id' => $shoppingUser->id, + 'is_for' => 'me', + 'email' => $user->email, + 'payone_userid' => 123456, + 'clearingtype' => 'wlt', + 'wallettype' => 'PPE', + 'active' => true, + 'status' => 3, + 'abo_interval' => 5, + 'next_date' => now()->toDateString(), + ]); + + $shoppingOrder = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $user->id, + 'member_id' => $user->id, + 'country_id' => $shippingCountry->id, + 'user_shop_id' => $userShop->id, + 'payment_for' => 3, + 'total' => 100, + 'subtotal' => 90, + 'total_shipping' => 100, + 'paid' => false, + 'is_abo' => true, + 'txaction' => 'failed', + 'mode' => 'test', + ]); + + $shoppingPayment = ShoppingPayment::create([ + 'shopping_order_id' => $shoppingOrder->id, + 'clearingtype' => 'wlt', + 'wallettype' => 'PPE', + 'reference' => 'RF123456', + 'amount' => 10000, + 'currency' => 'EUR', + 'mode' => 'test', + 'is_abo' => true, + 'abo_interval' => 5, + ]); + + PaymentTransaction::create([ + 'shopping_payment_id' => $shoppingPayment->id, + 'request' => 'debit', + 'txid' => 1, + 'userid' => 123456, + 'status' => 'ERROR', + 'txaction' => 'failed', + 'errorcode' => 923, + 'errormessage' => 'Keine Deckung', + 'mode' => 'test', + ]); + + $userAboOrder = UserAboOrder::create([ + 'user_abo_id' => $userAbo->id, + 'shopping_order_id' => $shoppingOrder->id, + 'status' => 3, + 'paid' => false, + ]); + + return [ + 'userAbo' => $userAbo, + 'userAboOrder' => $userAboOrder, + ]; +} diff --git a/tests/Unit/Services/DatevExportServiceTest.php b/tests/Unit/Services/DatevExportServiceTest.php index ca1f557..6eaae9d 100644 --- a/tests/Unit/Services/DatevExportServiceTest.php +++ b/tests/Unit/Services/DatevExportServiceTest.php @@ -458,6 +458,64 @@ class DatevExportServiceTest extends TestCase $this->assertEquals(15.50, $result); } + /** @test */ + public function it_parses_homeparty_ek_tax_split_format_with_preferred_key() + { + $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, ['vk_tax' => '15.50', 'ek_tax' => '7.50'], 'ek_tax'); + + $this->assertEquals(7.50, $result); + } + + /** @test */ + public function it_parses_homeparty_ek_net_split_format_with_preferred_key() + { + $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); + $method->setAccessible(true); + + $result = $method->invoke($this->service, ['vk_net' => '123.45', 'ek_net' => '67.89'], 'ek_net'); + + $this->assertEquals(67.89, $result); + } + + /** @test */ + public function it_resolves_net_split_from_collective_order_when_order_has_none() + { + $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); + $method->setAccessible(true); + + $collectOrder = new \stdClass; + $collectOrder->net_split = ['19' => '533.61']; + + $order = new \stdClass; + $order->net_split = null; + $order->shopping_collect_order = $collectOrder; + + $result = $method->invoke($this->service, $order); + + $this->assertEquals(['19' => '533.61'], $result); + } + + /** @test */ + public function it_prefers_order_net_split_over_collective_order_net_split() + { + $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); + $method->setAccessible(true); + + $collectOrder = new \stdClass; + $collectOrder->net_split = ['19' => '533.61']; + + $order = new \stdClass; + $order->net_split = ['19' => '677.51']; + $order->shopping_collect_order = $collectOrder; + + $result = $method->invoke($this->service, $order); + + $this->assertEquals(['19' => '677.51'], $result); + } + /* |-------------------------------------------------------------------------- | Model Tests