Steuerberater Modul tax
This commit is contained in:
parent
0f82fea88a
commit
245c281541
22 changed files with 1489 additions and 139 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<int, string>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
238
app/Services/AboRetryPaymentService.php
Normal file
238
app/Services/AboRetryPaymentService.php
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Cron\UserMakeOrder;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Services\Incentive\IncentiveTracker;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AboRetryPaymentService
|
||||
{
|
||||
/**
|
||||
* @return array{success: bool, message: string, order_id?: int}
|
||||
*/
|
||||
public function retry(UserAbo $userAbo): array
|
||||
{
|
||||
if ($userAbo->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, string>
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
dev/steuerberater/steuerberater-abgleich-status.csv
Normal file
17
dev/steuerberater/steuerberater-abgleich-status.csv
Normal file
|
|
@ -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
|
||||
|
310
dev/steuerberater/steuerberater-abgleich-status.html
Normal file
310
dev/steuerberater/steuerberater-abgleich-status.html
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Steuerberater-Abgleich DATEV - Status</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f8fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--ok-bg: #ecfdf3;
|
||||
--ok-text: #065f46;
|
||||
--warn-bg: #fffbeb;
|
||||
--warn-text: #92400e;
|
||||
--open-bg: #eff6ff;
|
||||
--open-text: #1e3a8a;
|
||||
--danger-bg: #fef2f2;
|
||||
--danger-text: #991b1b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1240px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.kpi .value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi .label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.callout strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.callout.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.callout.ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-text);
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-text);
|
||||
}
|
||||
|
||||
.status.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.status.open {
|
||||
background: var(--open-bg);
|
||||
color: var(--open-text);
|
||||
}
|
||||
|
||||
.status.danger {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<section class="card">
|
||||
<h1>Steuerberater-Abgleich DATEV (Stand nach Korrekturen)</h1>
|
||||
<div class="meta">
|
||||
Basis: <code>steuerberater.csv</code> vs. August-Export DATEV plus DB-Abgleich
|
||||
(<code>user_invoices</code>, <code>shopping_orders</code>, <code>datev_export_lines</code>).
|
||||
</div>
|
||||
<div class="kpis" style="margin-top: 14px;">
|
||||
<div class="kpi">
|
||||
<div class="value">66</div>
|
||||
<div class="label">Rueckmeldezeilen Steuerberater</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="value">30</div>
|
||||
<div class="label">Nicht im August-Export enthalten</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="value">27</div>
|
||||
<div class="label">Davon DB-Periode September 2025</div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="value">3</div>
|
||||
<div class="label">Unter Rechnungsnummer nicht in DB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="callout warn">
|
||||
<strong>Auffaelliges Muster</strong>
|
||||
24 Rueckmeldefaelle entsprechen dem Schema <em>Betrag Test + 19%</em>. Neben Netto/Brutto-Missverstaendnis gab es
|
||||
echte technische Split-Fehler bei Sammelrechnungen und Homeparty.
|
||||
</div>
|
||||
<div class="callout ok">
|
||||
<strong>Nach Umsetzung</strong>
|
||||
Sammelrechnungen nutzen jetzt Netto+Steuer korrekt, Homeparty liest <code>ek_tax/ek_net</code>.
|
||||
Kritische Beispielbelege laufen auf die Rechnungstotals.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Umsetzung und offene Themen</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thema</th>
|
||||
<th>Ist-Zustand / Ursache</th>
|
||||
<th>Aenderung / Entscheidung</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Sammelrechnungen/API</td>
|
||||
<td>Export enthielt nur Steueranteil, weil <code>net_split</code> auf <code>ShoppingOrder</code> fehlte.</td>
|
||||
<td><code>net_split</code> wird gespeichert; historischer Fallback auf <code>ShoppingCollectOrder.net_split</code>.</td>
|
||||
<td><span class="status ok">Umgesetzt</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Homeparty</td>
|
||||
<td>Export las <code>vk_*</code>; Rechnungstotal basiert auf <code>ek_*</code>.</td>
|
||||
<td>DATEV-Service nutzt fuer Homeparty nun <code>ek_tax/ek_net</code>.</td>
|
||||
<td><span class="status ok">Umgesetzt</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>USt-ID pro Bestellung</td>
|
||||
<td>Validierung erfolgt aktuell beim Eintragen, nicht bei jeder Bestellung.</td>
|
||||
<td>Folgeticket: pro Bestellung pruefen; bei ungueltig Warnung + USt-Berechnung.</td>
|
||||
<td><span class="status open">Offen</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Storno in August</td>
|
||||
<td>Vom Steuerberater als Storno markiert, aber keine Stornorechnungslogik aktiv gewesen.</td>
|
||||
<td>Fuer August kein DATEV-Storno ableiten, solange keine echte Stornorechnung existiert.</td>
|
||||
<td><span class="status warn">Fachlich geklaert</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Doppelzahlung / Payone</td>
|
||||
<td>DATEV-Export belegt keine echte Doppelabbuchung.</td>
|
||||
<td>Abgleich ueber <code>payment_transactions</code> plus PAYONE Portal/API erforderlich.</td>
|
||||
<td><span class="status open">Offen</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Logik-Hinweise 8125 vs. 8400</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rechnung</th>
|
||||
<th>Exportkonto</th>
|
||||
<th>Systembefund</th>
|
||||
<th>Einordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>202536848 / 202537290 / 202537832</td>
|
||||
<td>8400</td>
|
||||
<td>AT-Faelle ohne verifizierte USt-ID, <code>taxable_sales=2</code></td>
|
||||
<td>8400 ist aus Systemdaten plausibel; fuer 8125 fehlt USt-ID-Grundlage.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>202537251</td>
|
||||
<td>8125 / BU 1</td>
|
||||
<td>ES-USt-ID vorhanden, <code>reverse_charge=1</code></td>
|
||||
<td>Export folgt Rechnungsstand und ist fachlich konsistent.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>202538445 / 202538446 / 202538774 / 202539011 / 202539399</td>
|
||||
<td>Nicht in August-Datei</td>
|
||||
<td>DB-Periode September 2025</td>
|
||||
<td>Kein August-Exportfehler; separat gegen September-Export pruefen.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Fehlende Belege im August-Export</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rechnung</th>
|
||||
<th>DB-Befund</th>
|
||||
<th>Einordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>202506145 / 202506147 / 202538333</td>
|
||||
<td><span class="status danger">Nicht in DB gefunden</span></td>
|
||||
<td>Separat klaeren: Datenstand, Rechnungsformat oder extern erzeugte Rechnung.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
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
|
||||
</td>
|
||||
<td><span class="status warn">DB-Periode September 2025</span></td>
|
||||
<td>Kein August-Exportfehler.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
58
dev/steuerberater/steuerberater-abgleich-status.md
Normal file
58
dev/steuerberater/steuerberater-abgleich-status.md
Normal file
|
|
@ -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`
|
||||
|
||||
67
dev/steuerberater/steuerberater.csv
Normal file
67
dev/steuerberater/steuerberater.csv
Normal file
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1,111 +1,120 @@
|
|||
|
||||
<!-- Info -->
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.start_date') }}</div>
|
||||
{{ $user_abo->start_date }}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.next_date') }}</div>
|
||||
<strong>{{ $user_abo->next_date }}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.abo_delivery_day') }}</div>
|
||||
<strong>{{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.last_date') }}</div>
|
||||
{{ $user_abo->last_date }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.start_date') }}</div>
|
||||
{{ $user_abo->start_date }}
|
||||
</div>
|
||||
<hr class="m-0">
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
|
||||
{!! $user_abo->getStatusFormated() !!} {!! get_active_badge($user_abo->active) !!}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.abo_delivery') }}</div>
|
||||
{{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.payment') }}</div>
|
||||
{{ $user_abo->getPaymentType() }}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.amount') }}</div>
|
||||
<span class="text-nowrap" id="value-amount">{{ $user_abo->getFormattedAmount() }}</span> €
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.next_date') }}</div>
|
||||
<strong>{{ $user_abo->next_date }}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.abo_delivery_day') }}</div>
|
||||
<strong>{{ \App\Services\HTMLHelper::getAboStrLang($user_abo->abo_interval) }}</strong>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.last_date') }}</div>
|
||||
{{ $user_abo->last_date }}
|
||||
</div>
|
||||
|
||||
<hr class="m-0">
|
||||
@if($isAdmin)
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-toggle="modal" data-target="#modals-load-content"
|
||||
data-id="{{ $user_abo->id }}"
|
||||
data-action="abo_update_settings"
|
||||
data-view="admin"
|
||||
data-route="{{ route('modal_load') }}"><span class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
@if(isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0)
|
||||
<div class="text-muted small">{{ __('tables.adviser') }}</div>
|
||||
{!! '<a href="'.route('admin_lead_edit', [$user_abo->shopping_user->member_id]).'">'.$user_abo->shopping_user->member->getFullName().'</a>' !!}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-0">
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.status') }} / {{ __('tables.active') }}</div>
|
||||
{!! $user_abo->getStatusFormated() !!} {!! get_active_badge($user_abo->active) !!}
|
||||
@if ($user_abo->status === 3 && $user_abo->active)
|
||||
<button type="button" class="btn btn-sm btn-danger mt-3" data-toggle="modal"
|
||||
data-target="#modal-retry-abo-payment">
|
||||
<span class="fa fa-redo"></span> {{ __('abo.retry_payment') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.abo_delivery') }}</div>
|
||||
{{ $user_abo->getCountPaidOrders() }} / {{ \App\Models\Setting::getContentBySlug('abo-min-duration') }}
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.is_for') }}</div>
|
||||
{!! $user_abo->getIsForFormated() !!}
|
||||
@if($user_abo->is_for === 'me')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('admin_lead_edit', $user_abo->user_id) }}"><i class="fa fa-edit"></i></a>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.payment') }}</div>
|
||||
{{ $user_abo->getPaymentType() }}
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.amount') }}</div>
|
||||
<span class="text-nowrap" id="value-amount">{{ $user_abo->getFormattedAmount() }}</span> €
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="m-0">
|
||||
@if ($isAdmin)
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-toggle="modal"
|
||||
data-target="#modals-load-content" data-id="{{ $user_abo->id }}" data-action="abo_update_settings"
|
||||
data-view="admin" data-route="{{ route('modal_load') }}"><span
|
||||
class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button>
|
||||
|
||||
@if($user_abo->is_for === 'ot')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('admin_customer_edit', $user_abo->shopping_user->id) }}"><i class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
@if(App\Services\AboHelper::canEditAbo($user_abo, $view))
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-toggle="modal" data-target="#modals-load-content"
|
||||
data-id="{{ $user_abo->id }}"
|
||||
data-action="abo_update_settings"
|
||||
data-view="{{ $view }}"
|
||||
data-route="{{ route('modal_load') }}"><span class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
{!! $user_abo->getIsForFormated() !!}
|
||||
@if($user_abo->is_for === 'me')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('user_edit') }}"><i class="fa fa-edit"></i></a>
|
||||
|
||||
@endif
|
||||
|
||||
@if($user_abo->is_for === 'ot')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('user_customer_edit', $user_abo->shopping_user->id) }}"><i class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
@if (isset($user_abo->shopping_user) && $user_abo->shopping_user->member_id > 0)
|
||||
<div class="text-muted small">{{ __('tables.adviser') }}</div>
|
||||
{!! '<a href="' .
|
||||
route('admin_lead_edit', [$user_abo->shopping_user->member_id]) .
|
||||
'">' .
|
||||
$user_abo->shopping_user->member->getFullName() .
|
||||
'</a>' !!}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="text-muted small">{{ __('tables.is_for') }}</div>
|
||||
{!! $user_abo->getIsForFormated() !!}
|
||||
@if ($user_abo->is_for === 'me')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('admin_lead_edit', $user_abo->user_id) }}"><i
|
||||
class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
|
||||
|
||||
@if ($user_abo->is_for === 'ot')
|
||||
<a class="btn btn-xs btn-secondary"
|
||||
href="{{ route('admin_customer_edit', $user_abo->shopping_user->id) }}"><i
|
||||
class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="card-body pb-1">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
@if (App\Services\AboHelper::canEditAbo($user_abo, $view))
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-toggle="modal"
|
||||
data-target="#modals-load-content" data-id="{{ $user_abo->id }}"
|
||||
data-action="abo_update_settings" data-view="{{ $view }}"
|
||||
data-route="{{ route('modal_load') }}"><span
|
||||
class="fa fa-edit"></span>{{ __('abo.abo_settings') }}</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
{!! $user_abo->getIsForFormated() !!}
|
||||
@if ($user_abo->is_for === 'me')
|
||||
<a class="btn btn-xs btn-secondary" href="{{ route('user_edit') }}"><i class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
|
||||
@if ($user_abo->is_for === 'ot')
|
||||
<a class="btn btn-xs btn-secondary"
|
||||
href="{{ route('user_customer_edit', $user_abo->shopping_user->id) }}"><i
|
||||
class="fa fa-edit"></i></a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -41,9 +41,18 @@
|
|||
|
||||
<th>{{__('tables.rf_no')}}</th>
|
||||
</tr>
|
||||
@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
|
||||
<tr class="border-top">
|
||||
<td>
|
||||
@if($isAdmin)
|
||||
|
|
@ -113,6 +122,25 @@
|
|||
{{ $user_abo_order->shopping_order->getLastShoppingPayment('reference') }}
|
||||
</td>
|
||||
</tr>
|
||||
@if($hasPaymentError)
|
||||
<tr>
|
||||
<td colspan="{{ $executionColspan }}" class="pt-0">
|
||||
<div class="alert alert-danger py-2 px-3 mb-2">
|
||||
<strong>{{ __('payment.payment_error') }}:</strong>
|
||||
@if($paymentErrorTransaction->errorcode)
|
||||
<span class="badge badge-danger">{{ $paymentErrorTransaction->errorcode }}</span>
|
||||
@endif
|
||||
{{ $paymentErrorTransaction->errormessage ?: $paymentErrorTransaction->customermessage }}
|
||||
@if($paymentErrorTransaction->error_description)
|
||||
<span class="text-muted">({{ $paymentErrorTransaction->error_description }})</span>
|
||||
@endif
|
||||
<div class="small text-muted mt-1">
|
||||
{{ $paymentErrorTransaction->created_at->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if (Session::has('alert-success'))
|
||||
<div class="col-sm-12">
|
||||
<div class="alert alert-success p-2 mt-2">
|
||||
<ul>
|
||||
<li>{{ Session::get('alert-success') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="card">
|
||||
@include('admin.abo._detail')
|
||||
</div>
|
||||
|
|
@ -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',
|
||||
]) !!}
|
||||
<input type="hidden" name="is_for" value="{{ $user_abo->is_for }}">
|
||||
<div class="card mt-3">
|
||||
@include('admin.abo._order_abo')
|
||||
@include('admin.abo._order_abo', ['add_only_mode' => false])
|
||||
</div>
|
||||
|
||||
@if ($comp_products && Yard::instance('shopping')->getNumComp() > 0)
|
||||
|
|
@ -109,6 +119,51 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($user_abo->status === 3 && $user_abo->active)
|
||||
<div class="modal fade" id="modal-retry-abo-payment" tabindex="-1" role="dialog" aria-labelledby="modal-retry-abo-payment-label" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
{!! Form::open(['action' => route('admin_abos_retry_payment', [$user_abo->id])]) !!}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-retry-abo-payment-label">{{ __('abo.retry_payment') }}</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>{{ __('abo.retry_payment_confirm_title') }}</strong><br>
|
||||
{{ __('abo.retry_payment_confirm_copy') }}
|
||||
</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tr>
|
||||
<td class="font-weight-bold">{{ __('navigation.abo') }}:</td>
|
||||
<td>#{{ $user_abo->id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">{{ __('tables.payment') }}:</td>
|
||||
<td>{{ $user_abo->getPaymentType() }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">{{ __('tables.amount') }}:</td>
|
||||
<td>{{ $user_abo->getFormattedAmount() }} €</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">{{ __('tables.next_date') }}:</td>
|
||||
<td>{{ $user_abo->next_date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ __('close') }}</button>
|
||||
<button type="submit" class="btn btn-warning">{{ __('abo.retry_payment_confirm_button') }}</button>
|
||||
</div>
|
||||
{!! Form::close() !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
|
|
|
|||
|
|
@ -23,8 +23,34 @@
|
|||
{!! HTMLHelper::getAboDeliveryOptions($user_abo->abo_interval) !!}
|
||||
</select>
|
||||
</div>
|
||||
@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
|
||||
<div class="col-sm-6">
|
||||
<label for="abo_next_month" class="form-label">{{ __('validation.attributes.month') }}*</label>
|
||||
<select class="custom-select" name="abo_next_month" id="abo_next_month" required>
|
||||
@foreach(range(0, 3) as $monthOffset)
|
||||
@php
|
||||
$executionMonth = now()->copy()->startOfMonth()->addMonths($monthOffset);
|
||||
@endphp
|
||||
<option value="{{ $executionMonth->format('Y-m') }}" @if($executionMonth->format('Y-m') === $selectedNextMonth) selected @endif>
|
||||
{{ \App\Services\HTMLHelper::getMonth($executionMonth->month) }} {{ $executionMonth->year }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
<div class="col-sm-12 mt-1">
|
||||
<i class="text-muted">{{ __('abo.abo_copy_abo_interval') }}</i>
|
||||
<i class="text-muted">
|
||||
@if($data['view'] === 'admin')
|
||||
{{ __('abo.admin_abo_copy_next_date') }}
|
||||
@else
|
||||
{{ __('abo.abo_copy_abo_interval') }}
|
||||
@endif
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Repositories\AboRepository;
|
||||
use App\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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<string, mixed> $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));
|
||||
}
|
||||
|
|
|
|||
207
tests/Feature/AdminAboRetryPaymentTest.php
Normal file
207
tests/Feature/AdminAboRetryPaymentTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use App\Cron\UserMakeOrder;
|
||||
use App\Models\Country;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\Shipping;
|
||||
use App\Models\ShippingCountry;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\ShoppingPayment;
|
||||
use App\Models\ShoppingUser;
|
||||
use App\Models\UserAbo;
|
||||
use App\Models\UserAboOrder;
|
||||
use App\Models\UserShop;
|
||||
use App\Services\AboHelper;
|
||||
use App\Services\AboRetryPaymentService;
|
||||
use App\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('zeigt Zahlungsfehler unter fehlgeschlagenen Abo-Ausführungen an', function () {
|
||||
$fixture = createAdminAboRetryFixture();
|
||||
|
||||
$html = view('admin.abo._executions', [
|
||||
'user_abo' => $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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue