Steuerberater Modul tax

This commit is contained in:
Kevin Adametz 2026-05-08 15:34:57 +02:00
parent 0f82fea88a
commit 245c281541
22 changed files with 1489 additions and 139 deletions

View 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;
}
}

View file

@ -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';
}
}