User-Panel-Restarbeiten: PM-Guard, Profil-Rework, USt-ID-Prüfung, Buchungspflicht-Adresse
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
036a53499f
commit
afcca34f91
25 changed files with 905 additions and 140 deletions
19
app/Enums/VatIdCheckStatus.php
Normal file
19
app/Enums/VatIdCheckStatus.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Ergebnis der USt-ID-Prüfung (Format + optionale eVatR-Online-Abfrage).
|
||||
*/
|
||||
enum VatIdCheckStatus: string
|
||||
{
|
||||
case Valid = 'valid';
|
||||
case Invalid = 'invalid';
|
||||
case FormatInvalid = 'format_invalid';
|
||||
case Unverified = 'unverified';
|
||||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this !== self::Invalid && $this !== self::FormatInvalid;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ class CheckoutController extends Controller
|
|||
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if ($user->subscribed()) {
|
||||
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
|
||||
}
|
||||
|
|
@ -45,6 +49,10 @@ class CheckoutController extends Controller
|
|||
|
||||
public function singlePm(Request $request): Checkout|RedirectResponse
|
||||
{
|
||||
if (! $request->user()->hasCompleteBillingAddress()) {
|
||||
return $this->backToProfile();
|
||||
}
|
||||
|
||||
if (! config('billing.single_pm_stripe_price_id')) {
|
||||
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
|
||||
}
|
||||
|
|
@ -81,4 +89,16 @@ class CheckoutController extends Controller
|
|||
->route('me.bookings.index')
|
||||
->with('checkout-notice', $notice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
|
||||
* Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf
|
||||
* der Profil-Seite über dem Rechnungsadress-Formular.
|
||||
*/
|
||||
private function backToProfile(): RedirectResponse
|
||||
{
|
||||
return redirect()
|
||||
->route('me.profile')
|
||||
->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ class ProcessStripeWebhook
|
|||
$local = $user->billingAddress;
|
||||
|
||||
return InvoiceBillingAddress::query()->create([
|
||||
'company' => $local?->company,
|
||||
'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name,
|
||||
'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '',
|
||||
'address2' => $stripeAddress['line2'] ?? $local?->address2,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ class BillingAddress extends Model
|
|||
'user_id',
|
||||
'salutation_key',
|
||||
'title',
|
||||
'company',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
|
|
@ -27,4 +30,17 @@ class BillingAddress extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung?
|
||||
* Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land.
|
||||
*/
|
||||
public function isComplete(): bool
|
||||
{
|
||||
return filled($this->name)
|
||||
&& filled($this->address1)
|
||||
&& filled($this->postal_code)
|
||||
&& filled($this->city)
|
||||
&& filled($this->country_code);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ class InvoiceBillingAddress extends Model
|
|||
protected $fillable = [
|
||||
'salutation_key',
|
||||
'title',
|
||||
'company',
|
||||
'name',
|
||||
'address1',
|
||||
'address2',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ class User extends Authenticatable
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und
|
||||
* Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse.
|
||||
*/
|
||||
public function hasCompleteBillingAddress(): bool
|
||||
{
|
||||
return (bool) $this->billingAddress?->isComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
|
||||
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class ManualInvoiceService
|
|||
$addressSnapshot = InvoiceBillingAddress::query()->create([
|
||||
'salutation_key' => $billingAddress->salutation_key,
|
||||
'title' => $billingAddress->title,
|
||||
'company' => $billingAddress->company,
|
||||
'name' => $billingAddress->name,
|
||||
'address1' => $billingAddress->address1,
|
||||
'address2' => $billingAddress->address2,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Services\Billing;
|
|||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Checkout;
|
||||
|
||||
/**
|
||||
|
|
@ -27,11 +28,51 @@ class StripeCheckoutService
|
|||
? $plan->stripe_price_id_yearly
|
||||
: $plan->stripe_price_id_monthly;
|
||||
|
||||
$this->syncTaxIdFromBillingAddress($user);
|
||||
|
||||
return $user
|
||||
->newSubscription('default', $priceId)
|
||||
->checkout($this->sessionOptions());
|
||||
}
|
||||
|
||||
/**
|
||||
* Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer
|
||||
* übergeben (User-Panel-Restarbeiten, 12.06.2026) — Stripe Tax
|
||||
* berücksichtigt sie dann ohne erneute Eingabe im Checkout. Fehler
|
||||
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
|
||||
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
|
||||
*/
|
||||
private function syncTaxIdFromBillingAddress(User $user): void
|
||||
{
|
||||
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
|
||||
|
||||
if ($vatId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
|
||||
|
||||
if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$user->createOrGetStripeCustomer();
|
||||
|
||||
$alreadySet = collect($user->taxIds())
|
||||
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
|
||||
|
||||
if (! $alreadySet) {
|
||||
$user->createTaxId('eu_vat', $vatId);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige
|
||||
* Kundenadresse — die im Checkout erfasste Rechnungsadresse (und der
|
||||
|
|
@ -69,6 +110,8 @@ class StripeCheckoutService
|
|||
*/
|
||||
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
|
||||
{
|
||||
$this->syncTaxIdFromBillingAddress($user);
|
||||
|
||||
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
|
||||
...$this->sessionOptions(),
|
||||
'metadata' => ['single_purchase_id' => (string) $purchase->id],
|
||||
|
|
|
|||
148
app/Services/Billing/VatIdValidationService.php
Normal file
148
app/Services/Billing/VatIdValidationService.php
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Enums\VatIdCheckStatus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Prüft USt-IDs in zwei Stufen (User-Panel-Restarbeiten, 12.06.2026):
|
||||
*
|
||||
* 1. **Formatprüfung** (immer): EU-Formatrahmen über den VatResolver,
|
||||
* inkl. Abgleich mit dem Land der Rechnungsadresse.
|
||||
* 2. **eVatR-Online-Abfrage** (wenn möglich): REST-API des BZSt
|
||||
* (`POST /app/v1/abfrage`, Status `evatr-0000` = gültig). Die Abfrage
|
||||
* setzt die eigene deutsche USt-ID des Betreibers voraus
|
||||
* (`billing.own_vat_id`) und kann nur ausländische EU-IDs bestätigen —
|
||||
* deutsche IDs und Ausfälle der API führen zu `Unverified`, nie zu
|
||||
* einem harten Fehler. Stripe validiert die im Checkout erfasste
|
||||
* USt-ID zusätzlich selbst; diese Prüfung verbessert die Eingabe-UX
|
||||
* und sichert den manuellen MAN-Rechnungskreis ab.
|
||||
*/
|
||||
class VatIdValidationService
|
||||
{
|
||||
private const EVATR_ENDPOINT = 'https://api.evatr.vies.bzst.de/app/v1/abfrage';
|
||||
|
||||
private const STATUS_VALID = 'evatr-0000';
|
||||
|
||||
public function __construct(private readonly VatResolver $vatResolver) {}
|
||||
|
||||
/**
|
||||
* @return array{status: VatIdCheckStatus, message: string}
|
||||
*/
|
||||
public function check(string $vatId, ?string $billingCountryCode = null): array
|
||||
{
|
||||
$vatId = strtoupper((string) preg_replace('/\s+/', '', $vatId));
|
||||
|
||||
if ($vatId === '') {
|
||||
return $this->result(VatIdCheckStatus::Unverified, __('Keine USt-ID angegeben.'));
|
||||
}
|
||||
|
||||
$prefix = substr($vatId, 0, 2);
|
||||
$prefixCountry = $prefix === 'EL' ? 'GR' : $prefix;
|
||||
|
||||
if (! $this->vatResolver->isPlausibleVatId($vatId, $prefixCountry)) {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::FormatInvalid,
|
||||
__('Das Format der USt-ID ist ungültig (erwartet: Ländercode + Kennziffer, z. B. DE123456789).'),
|
||||
);
|
||||
}
|
||||
|
||||
$billingCountryCode = strtoupper((string) $billingCountryCode);
|
||||
|
||||
if ($billingCountryCode !== '' && ! $this->vatResolver->isPlausibleVatId($vatId, $billingCountryCode)) {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::FormatInvalid,
|
||||
__('Die USt-ID passt nicht zum Land der Rechnungsadresse (:country).', ['country' => $billingCountryCode]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($prefix === 'DE') {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Deutsche USt-IDs können über eVatR nicht online bestätigt werden.'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'),
|
||||
);
|
||||
}
|
||||
|
||||
$ownVatId = (string) config('billing.own_vat_id');
|
||||
|
||||
if ($ownVatId === '') {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Online-Prüfung ist nicht konfiguriert (BILLING_OWN_VAT_ID).'),
|
||||
);
|
||||
}
|
||||
|
||||
return $this->confirmViaEvatr($vatId, $ownVatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: VatIdCheckStatus, message: string}
|
||||
*/
|
||||
private function confirmViaEvatr(string $vatId, string $ownVatId): array
|
||||
{
|
||||
$cacheKey = 'evatr-check:'.$vatId;
|
||||
|
||||
/** @var array{status: VatIdCheckStatus, message: string}|null $cached */
|
||||
$cached = Cache::get($cacheKey);
|
||||
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::timeout(8)
|
||||
->acceptJson()
|
||||
->post(self::EVATR_ENDPOINT, [
|
||||
'anfragendeUstid' => $ownVatId,
|
||||
'angefragteUstid' => $vatId,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
|
||||
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht erreichbar.'),
|
||||
);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return $this->result(
|
||||
VatIdCheckStatus::Unverified,
|
||||
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht verfügbar.'),
|
||||
);
|
||||
}
|
||||
|
||||
$status = (string) $response->json('status');
|
||||
|
||||
$result = $status === self::STATUS_VALID
|
||||
? $this->result(VatIdCheckStatus::Valid, __('USt-ID per eVatR (BZSt) bestätigt — gültig.'))
|
||||
: $this->result(
|
||||
VatIdCheckStatus::Invalid,
|
||||
__('Die USt-ID wurde von eVatR (BZSt) nicht bestätigt (Status :status).', ['status' => $status ?: 'unbekannt']),
|
||||
);
|
||||
|
||||
// Nur definitive Ergebnisse cachen — Ausfälle der API sollen beim
|
||||
// nächsten Versuch erneut geprüft werden.
|
||||
Cache::put($cacheKey, $result, now()->addHours(6));
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: VatIdCheckStatus, message: string}
|
||||
*/
|
||||
private function result(VatIdCheckStatus $status, string $message): array
|
||||
{
|
||||
return ['status' => $status, 'message' => $message];
|
||||
}
|
||||
}
|
||||
|
|
@ -49,8 +49,9 @@ class VatResolver
|
|||
/**
|
||||
* Formale Plausibilität: beginnt mit dem Ländercode der Adresse und
|
||||
* trägt danach 2–13 alphanumerische Zeichen (EU-Formatrahmen).
|
||||
* Public, damit der VatIdValidationService dieselbe Definition nutzt.
|
||||
*/
|
||||
private function isPlausibleVatId(?string $vatId, string $countryCode): bool
|
||||
public function isPlausibleVatId(?string $vatId, string $countryCode): bool
|
||||
{
|
||||
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? '');
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ return [
|
|||
|
||||
'vat_rate' => env('BILLING_VAT_RATE', 0.19),
|
||||
|
||||
// Eigene deutsche USt-ID des Betreibers — Pflichtangabe für die
|
||||
// eVatR-Online-Bestätigung ausländischer EU-USt-IDs (BZSt-REST-API).
|
||||
// Ohne sie bleibt die USt-ID-Prüfung eine reine Formatprüfung.
|
||||
'own_vat_id' => env('BILLING_OWN_VAT_ID'),
|
||||
|
||||
// EU-Mitgliedstaaten (ISO 3166-1 alpha-2), Stand 2026 — ohne DE,
|
||||
// das im VatResolver als Inland behandelt wird.
|
||||
'eu_country_codes' => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Rechnungsadresse vervollständigen (User-Panel-Restarbeiten, 12.06.2026):
|
||||
* Anrede/Titel existierten bereits, es fehlten Firmenname sowie getrennte
|
||||
* Vor-/Nachnamen. `name` bleibt als zusammengesetzte Empfängerzeile für
|
||||
* die Rechnungs-Snapshots erhalten und wird beim Speichern aus
|
||||
* Vor-/Nachname gefüllt. Die Snapshot-Tabelle bekommt `company` ebenfalls,
|
||||
* damit der Firmenname pro Rechnung eingefroren wird.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('billing_addresses', function (Blueprint $table): void {
|
||||
$table->string('company', 255)->nullable()->after('title');
|
||||
$table->string('first_name', 80)->nullable()->after('company');
|
||||
$table->string('last_name', 80)->nullable()->after('first_name');
|
||||
});
|
||||
|
||||
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
|
||||
$table->string('company', 255)->nullable()->after('title');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('billing_addresses', function (Blueprint $table): void {
|
||||
$table->dropColumn(['company', 'first_name', 'last_name']);
|
||||
});
|
||||
|
||||
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
|
||||
$table->dropColumn('company');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -5,6 +5,44 @@
|
|||
|
||||
---
|
||||
|
||||
## 2026-06-12 · User-Panel-Restarbeiten (Kevins Liste) ✅
|
||||
|
||||
- **Was**: Alle Punkte aus `docs/user-admin/User-Panel-Restarbeiten.md`
|
||||
(Status-Tabelle dort): (1) PM-Anlage ohne Firma zeigt eine Meldung mit
|
||||
„Firma anlegen"-Button statt des leeren Editors. (2) Profil-Seite neu
|
||||
gegliedert (Persönliche Daten / Konto / Rechnungsadresse / Einstellungen);
|
||||
Rechnungsadresse vervollständigt um Anrede, Vorname, Nachname,
|
||||
Firmenname (Migration `billing_addresses` + Snapshot-Spalte `company`
|
||||
in `invoice_billing_addresses`, von MAN-Lauf und STR-Spiegelung
|
||||
durchgereicht); „Persönliche Daten übernehmen"-Button gegen die
|
||||
Doppel-Eingabe. (3) Doppelte Validierungsmeldung behoben: fehlende
|
||||
Pflichtfelder melden einzeln unter dem Feld. (4) USt-ID-Validierung:
|
||||
Formatprüfung sofort (hartes Speicher-Gate) + eVatR-Online-Bestätigung
|
||||
(BZSt-REST-API, neuer `VatIdValidationService`, ENV `BILLING_OWN_VAT_ID`,
|
||||
6h-Cache nur für definitive Ergebnisse, Ausfälle degradieren sanft).
|
||||
(5) Rechnungsadresse ist Pflicht für jeden Checkout (Redirect aufs
|
||||
Profil mit Hinweis); USt-ID wird als Stripe-Tax-ID übergeben.
|
||||
(6) Firmenübersicht zeigt Logos auch für Legacy-Firmen (zentrale
|
||||
logoUrl-Auflösung statt verkürzter Fast-Variante); „Letzte PM" mit
|
||||
Jahreszahl. (7) Checkboxen → Flux-Switches (Boilerplate-Override,
|
||||
Footer-Code); Token-Abilities bleiben Checkbox-Gruppe. Befund zu den
|
||||
Profil-Schaltern: `show_stats`/`disable_footer_code` werden noch
|
||||
nirgends ausgewertet (greifen mit Web-Relaunch) — steht jetzt in den
|
||||
Beschreibungen.
|
||||
- **Dateien**: `customer/profile.blade.php` (Neufassung),
|
||||
`customer/press-releases/create.blade.php` (Guard),
|
||||
`customer/press-kits/index.blade.php` (Logo/Datum),
|
||||
`app/Services/Billing/VatIdValidationService.php` (neu) +
|
||||
`VatIdCheckStatus`-Enum, `VatResolver` (isPlausibleVatId public),
|
||||
`StripeCheckoutService` (Tax-ID-Sync), `CheckoutController` (Guard),
|
||||
`User::hasCompleteBillingAddress()`, `BillingAddress::isComplete()`,
|
||||
Migration, `config/billing.php` (`own_vat_id`).
|
||||
- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean; 14 neue Tests
|
||||
(VatId-Service, PM-Guard, Profil-Validierung, Checkout-Guard).
|
||||
- **Offene Fragen**: `BILLING_OWN_VAT_ID` in .env setzen (eigene USt-ID),
|
||||
sonst bleibt die Online-Prüfung aus; Entscheidung zu den beiden
|
||||
Profil-Schaltern (behalten vs. bis Relaunch ausblenden).
|
||||
|
||||
## 2026-06-12 · Responsive-Härtung (Block 3, Punkt 1) ✅
|
||||
|
||||
- **Was**: Systemische Responsive-Fehler behoben (Screenshots in
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ Stub-Spalte `users.press_release_quota` ist entfernt.
|
|||
| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) |
|
||||
| `me.checkout.billing-portal` (`/admin/me/checkout/abo-verwalten`) | Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung) |
|
||||
|
||||
**Buchungs-Voraussetzung** (12.06.2026): Jeder Checkout erfordert eine
|
||||
vollständige Rechnungsadresse (`User::hasCompleteBillingAddress()`,
|
||||
Pflicht: Nachname, Straße, PLZ, Ort, Land) — sonst Redirect aufs Profil
|
||||
mit Hinweis. Lokale Adresse (`User::stripeAddress()`) und USt-ID
|
||||
(`StripeCheckoutService::syncTaxIdFromBillingAddress`, Typ `eu_vat`)
|
||||
werden an den Stripe-Customer übergeben.
|
||||
|
||||
Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`).
|
||||
Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()`
|
||||
im AppServiceProvider, Netto-Preise mit `tax_behavior: exclusive`) — nach
|
||||
|
|
@ -159,6 +166,7 @@ Stripe/Cashier:
|
|||
| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) |
|
||||
| `STRIPE_PRICE_SINGLE_PM` | Stripe-Price-ID der Einzel-PM (legt `billing:sync-stripe-plans` an; ohne sie ist der Einzel-PM-Checkout deaktiviert) |
|
||||
| `BILLING_OWN_VAT_ID` | Eigene deutsche USt-ID des Betreibers — schaltet die eVatR-Online-Bestätigung ausländischer EU-USt-IDs frei (BZSt-REST-API, `VatIdValidationService`); ohne sie bleibt es bei der Formatprüfung |
|
||||
| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) |
|
||||
|
||||
**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,34 @@
|
|||
1. Pressemitteilungen anlegen ohne Firma hier würde ich das Create Formular nicht aufrufen lassen, wenn keine Firma existiert, sondern es muss eine Meldung erscheinen, dass ohne eine Firma keine Pressemitteilung angelegt werden kann.. Hintergrund öffnet man. Eine neue Pressemitteilung kann man keine Firma angeben und kann auch nicht speichern man muss wieder zurückspringen eine Firma anlegen und dann kann man erst entsprechend ein Presse Beitrag erstellen.
|
||||
|
||||
2. https://pressekonto.test/admin/me/profile die Darstellung ist noch nicht wirklich sauber hier kann noch etwas mehr Struktur rein. Grundsätzlich würde ich ab sofort Rechnungsadresse als Pflicht machen, wenn ein Paket gebucht wird und diese Rechnungsdaten gleich mit übergeben. Profil Einstellungen. Hier gibt es auch schon so etwas wie Anrede Vorname Telefon Risse etc. dieses doppelt sich mit der Rechnungsadresse zumindest in Teilen, denn die Rechnungsadresse ist technisch eigentlich gar nicht richtig vollständig hier viel zum Beispiel Firmenname Vorname Nachname Anrede.
|
||||
Profil
|
||||
1. https://pressekonto.test/admin/me/profile die Darstellung ist noch nicht wirklich sauber hier kann noch etwas mehr Struktur rein. Grundsätzlich würde ich ab sofort Rechnungsadresse als Pflicht machen, wenn ein Paket gebucht wird und diese Rechnungsdaten gleich mit übergeben. Profil Einstellungen. Hier gibt es auch schon so etwas wie Anrede Vorname Telefon Risse etc. dieses doppelt sich mit der Rechnungsadresse zumindest in Teilen, denn die Rechnungsadresse ist technisch eigentlich gar nicht richtig vollständig hier viel zum Beispiel Firmenname Vorname Nachname Anrede.
|
||||
|
||||
3. Validierung der Rechnungsadresse gebe ich etwas ein und drücke ich auf. Absenden bekomme ich die Fehlermeldung zweimal einmal direkt unter dem Feld und einmal unten.
|
||||
2. Validierung der Rechnungsadresse gebe ich etwas ein und drücke ich auf. Absenden bekomme ich die Fehlermeldung zweimal einmal direkt unter dem Feld und einmal unten.
|
||||
|
||||
4. Umsatzsteuer ID. Ich würde hier gerne schon mal eine Validierung der Umsatzsteuer ID haben, ob diese gültig ist oder macht das an dieser Stelle gar keinen Sinn, weil Strip das komplett übernimmt? Ich glaube es wär aber gut, wenn Firmen aus EU Land hier schon eine Steuernummer hinterlegen können und diese zumindest schon einmal während der Eingabe validiert wird, ob diese richtig ist. Diese kann dann ja Stripe übergeben werden.
|
||||
3. Umsatzsteuer ID. Ich würde hier gerne schon mal eine Validierung der Umsatzsteuer ID haben, ob diese gültig ist oder macht das an dieser Stelle gar keinen Sinn, weil Strip das komplett übernimmt? Ich glaube es wär aber gut, wenn Firmen aus EU Land hier schon eine Steuernummer hinterlegen können und diese zumindest schon einmal während der Eingabe validiert wird, ob diese richtig ist. Diese kann dann ja Stripe übergeben werden. https://api.evatr.vies.bzst.de/app/v1/abfrage
|
||||
|
||||
4. Unten gibt es noch Einstellungen Statistiken anzeigen und Footer code deaktivieren haben die aktuell eine Funktion?
|
||||
|
||||
Firmen:
|
||||
Wenn ich auf die Firmenübersicht gehe und eine Firma ein Logo hat, soll das auch in der Übersicht erscheinen und angezeigt werden. Jetzt ist da dann immer der Kürzel der Firma
|
||||
|
||||
Bei der Firma ist zusätzlich angegeben, letzte PM ihr fehlt Jahreszahl
|
||||
|
||||
Einheitlichkeit.
|
||||
|
||||
Statt Checkboxen, bitte die Switcher benutzen von Flux UI.
|
||||
|
||||
---
|
||||
|
||||
## Status (Claude, 12.06.2026)
|
||||
|
||||
| # | Punkt | Status |
|
||||
|---|---|---|
|
||||
| PM 1 | PM-Anlage ohne Firma | ✅ Das Formular erscheint nicht mehr; stattdessen Meldung „Ohne Firma kann keine Pressemitteilung angelegt werden" mit Button „Firma anlegen". |
|
||||
| Profil 1 | Struktur + Rechnungsadresse vollständig | ✅ Seite neu gegliedert (Persönliche Daten / Konto & Sicherheit / Rechnungsadresse / Einstellungen). Rechnungsadresse hat jetzt Anrede, Vorname, Nachname, Firmenname (optional) + Anschrift, mit Unterabschnitten Empfänger/Anschrift/Steuern und Button „Persönliche Daten übernehmen" gegen die Doppel-Eingabe. Rechnungsadresse ist ab sofort **Pflicht für jede Buchung** (Checkout leitet sonst mit Hinweis aufs Profil); Adresse und USt-ID werden an Stripe übergeben. |
|
||||
| Profil 2 | Doppelte Fehlermeldung | ✅ Statt einer generischen Sammelmeldung + Feldmeldung werden fehlende Pflichtfelder jetzt einzeln und genau einmal unter dem jeweiligen Feld gemeldet. |
|
||||
| Profil 3 | USt-ID-Validierung | ✅ Zweistufig: Formatprüfung sofort bei der Eingabe (hartes Gate beim Speichern), Online-Bestätigung über die BZSt-eVatR-REST-API (`api.evatr.vies.bzst.de`) live während der Eingabe. **Wichtig:** eVatR kann nur ausländische EU-IDs bestätigen und braucht unsere eigene deutsche USt-ID → ENV `BILLING_OWN_VAT_ID` setzen. Deutsche IDs werden nur formatgeprüft. Antwort auf die Frage: Stripe validiert die im Checkout erfasste ID zusätzlich selbst — unsere Prüfung verbessert die Eingabe-UX und sichert den manuellen MAN-Rechnungskreis ab, ersetzt Stripe also nicht, ergänzt es. Die lokal gepflegte USt-ID wird beim Checkout als Stripe-Tax-ID übergeben. |
|
||||
| Profil 4 | Schalter „Statistiken anzeigen" / „Footer-Code deaktivieren" | ⚠️ Befund: Beide Flags werden gespeichert (auch aus dem Legacy-Import), aber **noch nirgends ausgewertet** — die konsumierenden Features (öffentliche PM-Seiten mit Footer-Codes/Statistiken) kommen mit dem Web-Relaunch. Die Beschreibungen weisen jetzt darauf hin („Greift mit dem Relaunch der Portal-Seiten"). Entscheidung offen: behalten (empfohlen, Daten sind da) oder bis zum Relaunch ausblenden. |
|
||||
| Firmen 1 | Logo in der Übersicht | ✅ Die Übersicht nutzt jetzt dieselbe Logo-Auflösung wie die Detailseite (inkl. migrierter Legacy-Pfade) — vorher fielen alle Legacy-Firmen auf die Initialen zurück. |
|
||||
| Firmen 2 | „Letzte PM" ohne Jahr | ✅ Kartenansicht zeigt jetzt `d.m.Y`. |
|
||||
| Einheitlichkeit | Checkboxen → Flux-Switches | ✅ Umgestellt (Boilerplate-Override in PM-Create/-Edit, Footer-Code in Firma-Anlage/-Detail). Ausnahme: die Mehrfachauswahl der API-Token-Berechtigungen bleibt eine Checkbox-Gruppe — dort ist die Checkbox die semantisch richtige Flux-Komponente. |
|
||||
|
|
@ -201,7 +201,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
|
|||
</flux:field>
|
||||
|
||||
<flux:field class="sm:col-span-2">
|
||||
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
|
||||
<flux:switch wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use App\Services\Customer\CustomerCompanyContext;
|
|||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
|
|
@ -313,29 +312,13 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
|
||||
public function fastLogoUrl(Company $company): ?string
|
||||
{
|
||||
if (blank($company->logo_path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$logoPath = trim((string) $company->logo_path);
|
||||
|
||||
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
|
||||
return $logoPath;
|
||||
}
|
||||
|
||||
if (Str::startsWith($logoPath, '/storage/')) {
|
||||
return asset($logoPath);
|
||||
}
|
||||
|
||||
if (filled($company->legacy_portal)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
|
||||
return asset('storage/'.ltrim($logoPath, '/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
// Delegiert an die zentrale Auflösung inkl. der migrierten
|
||||
// Legacy-Pfade (company-logos/{portal}/{id}/…) — die frühere
|
||||
// „schnelle" Variante übersprang Legacy-Firmen komplett, wodurch
|
||||
// in der Übersicht trotz vorhandenem Logo nur die Initialen
|
||||
// erschienen. Die Existenz-Checks laufen auf dem lokalen Disk
|
||||
// und sind für 50 Karten pro Seite unkritisch.
|
||||
return $company->logoUrl();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -389,7 +372,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
|
|||
$company->setAttribute('panel_meta_line', $this->metaLine($company));
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_short',
|
||||
$lastPublishedAt?->format('d.m.') ?? '—'
|
||||
$lastPublishedAt?->format('d.m.Y') ?? '—'
|
||||
);
|
||||
$company->setAttribute(
|
||||
'panel_last_press_release_date',
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
|
|||
<flux:error name="companyCountryCode" />
|
||||
</flux:field>
|
||||
<flux:field>
|
||||
<flux:checkbox wire:model="companyDisableFooterCode" :label="__('Footer-Code deaktivieren')" />
|
||||
<flux:switch wire:model="companyDisableFooterCode" :label="__('Footer-Code deaktivieren')" />
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public string $placeholderVariant = '';
|
||||
|
||||
public bool $hasCompanies = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
|
||||
// Ohne Firma kein PM-Formular: statt eines leeren Editors, in dem
|
||||
// weder Firma wählbar noch Speichern möglich ist, erscheint eine
|
||||
// Meldung mit dem direkten Weg zur Firmen-Anlage.
|
||||
$this->hasCompanies = $context->companyCountFor($user) > 0;
|
||||
|
||||
if (! $this->hasCompanies) {
|
||||
return;
|
||||
}
|
||||
|
||||
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
|
||||
|
||||
if ($firstCompany) {
|
||||
|
|
@ -711,6 +723,33 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</header>
|
||||
|
||||
@if (! $hasCompanies)
|
||||
{{-- ============== KEINE FIRMA: MELDUNG STATT FORMULAR ============== --}}
|
||||
<article class="panel">
|
||||
<div class="p-8 flex flex-col items-center text-center gap-4">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
<flux:icon.building-office class="size-6" />
|
||||
</div>
|
||||
<div class="space-y-1 max-w-[520px]">
|
||||
<h2 class="text-[16px] font-semibold text-[color:var(--color-ink)] m-0">
|
||||
{{ __('Ohne Firma kann keine Pressemitteilung angelegt werden.') }}
|
||||
</h2>
|
||||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Jede Pressemitteilung erscheint im Namen einer Firma. Bitte legen Sie zuerst eine Firma an — danach können Sie hier direkt loslegen.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 pt-1">
|
||||
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
|
||||
{{ __('Firma anlegen') }}
|
||||
</flux:button>
|
||||
<flux:button variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zur PM-Übersicht') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@else
|
||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||
<div class="grid gap-6 pr-editor-layout">
|
||||
|
||||
|
|
@ -952,7 +991,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
— {{ __('Boilerplate aus Firma') }}
|
||||
</span>
|
||||
</span>
|
||||
<flux:checkbox
|
||||
<flux:switch
|
||||
wire:model.live="useBoilerplateOverride"
|
||||
:label="__('Für diese PM überschreiben')"
|
||||
/>
|
||||
|
|
@ -1334,4 +1373,5 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
:confirm-label="__('Zur Prüfung senden')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -901,7 +901,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
— {{ __('Boilerplate aus Firma') }}
|
||||
</span>
|
||||
</span>
|
||||
<flux:checkbox
|
||||
<flux:switch
|
||||
wire:model.live="useBoilerplateOverride"
|
||||
:label="__('Für diese PM überschreiben')"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\VatIdCheckStatus;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\VatIdValidationService;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
|
@ -35,7 +36,13 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public string $taxIdNumber = '';
|
||||
|
||||
public string $billingName = '';
|
||||
public string $billingSalutationKey = 'none';
|
||||
|
||||
public string $billingCompany = '';
|
||||
|
||||
public string $billingFirstName = '';
|
||||
|
||||
public string $billingLastName = '';
|
||||
|
||||
public string $billingAddress1 = '';
|
||||
|
||||
|
|
@ -47,6 +54,10 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public string $billingCountryCode = 'DE';
|
||||
|
||||
public ?string $vatCheckStatus = null;
|
||||
|
||||
public ?string $vatCheckMessage = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
|
@ -68,17 +79,72 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
|
||||
|
||||
$billingAddress = $user->billingAddress;
|
||||
$this->billingName = (string) ($billingAddress?->name ?? '');
|
||||
$this->billingSalutationKey = (string) ($billingAddress?->salutation_key ?? 'none');
|
||||
$this->billingCompany = (string) ($billingAddress?->company ?? '');
|
||||
$this->billingFirstName = (string) ($billingAddress?->first_name ?? '');
|
||||
$this->billingLastName = (string) ($billingAddress?->last_name ?? '');
|
||||
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
|
||||
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
|
||||
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
|
||||
$this->billingCity = (string) ($billingAddress?->city ?? '');
|
||||
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
|
||||
|
||||
// Bestandsdaten vor der Feld-Trennung: `name` war eine freie
|
||||
// Empfängerzeile — einmalig in Vor-/Nachname aufteilen.
|
||||
if (blank($this->billingFirstName) && blank($this->billingLastName) && filled($billingAddress?->name)) {
|
||||
$parts = preg_split('/\s+/u', trim((string) $billingAddress->name)) ?: [];
|
||||
$this->billingLastName = (string) array_pop($parts);
|
||||
$this->billingFirstName = implode(' ', $parts);
|
||||
}
|
||||
|
||||
if (filled($this->taxIdNumber)) {
|
||||
$this->refreshVatCheck();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persönliche Daten als Rechnungsempfänger übernehmen — löst die von
|
||||
* Kevin angemerkte Doppel-Eingabe auf, ohne die Datensätze zu koppeln.
|
||||
*/
|
||||
public function copyProfileToBilling(): void
|
||||
{
|
||||
$this->billingSalutationKey = $this->salutationKey;
|
||||
$this->billingFirstName = $this->firstName;
|
||||
$this->billingLastName = $this->lastName;
|
||||
|
||||
if (filled($this->countryCode)) {
|
||||
$this->billingCountryCode = $this->countryCode;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedTaxIdNumber(): void
|
||||
{
|
||||
$this->refreshVatCheck();
|
||||
}
|
||||
|
||||
public function updatedBillingCountryCode(): void
|
||||
{
|
||||
$this->refreshVatCheck();
|
||||
}
|
||||
|
||||
private function refreshVatCheck(): void
|
||||
{
|
||||
if (blank($this->taxIdNumber)) {
|
||||
$this->vatCheckStatus = null;
|
||||
$this->vatCheckMessage = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = app(VatIdValidationService::class)->check($this->taxIdNumber, $this->billingCountryCode);
|
||||
|
||||
$this->vatCheckStatus = $result['status']->value;
|
||||
$this->vatCheckMessage = $result['message'];
|
||||
}
|
||||
|
||||
public function saveProfile(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'language' => ['required', Rule::in(['de', 'en'])],
|
||||
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
|
||||
|
|
@ -90,18 +156,47 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
||||
'taxIdNumber' => ['nullable', 'string', 'max:255'],
|
||||
'billingName' => ['nullable', 'string', 'max:255'],
|
||||
'billingSalutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
|
||||
'billingCompany' => ['nullable', 'string', 'max:255'],
|
||||
'billingFirstName' => ['nullable', 'string', 'max:80'],
|
||||
'billingLastName' => ['nullable', 'string', 'max:80'],
|
||||
'billingAddress1' => ['nullable', 'string', 'max:255'],
|
||||
'billingAddress2' => ['nullable', 'string', 'max:255'],
|
||||
'billingPostalCode' => ['nullable', 'string', 'max:20'],
|
||||
'billingCity' => ['nullable', 'string', 'max:120'],
|
||||
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
|
||||
];
|
||||
|
||||
// Sobald irgendein Rechnungsfeld gefüllt ist, werden die
|
||||
// Pflichtfelder einzeln eingefordert — die Meldung erscheint genau
|
||||
// einmal unter dem jeweils fehlenden Feld (vorher: eine generische
|
||||
// Sammelmeldung zusätzlich zur Feldmeldung).
|
||||
if ($this->billingHasInput()) {
|
||||
$rules['billingLastName'] = ['required', 'string', 'max:80'];
|
||||
$rules['billingAddress1'] = ['required', 'string', 'max:255'];
|
||||
$rules['billingPostalCode'] = ['required', 'string', 'max:20'];
|
||||
$rules['billingCity'] = ['required', 'string', 'max:120'];
|
||||
$rules['billingCountryCode'] = ['required', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))];
|
||||
}
|
||||
|
||||
$validated = $this->validate($rules, attributes: [
|
||||
'billingLastName' => __('Nachname (Rechnung)'),
|
||||
'billingAddress1' => __('Straße und Hausnummer'),
|
||||
'billingPostalCode' => __('PLZ'),
|
||||
'billingCity' => __('Ort'),
|
||||
'billingCountryCode' => __('Land'),
|
||||
]);
|
||||
|
||||
if ($this->billingHasInput() && ! $this->billingIsComplete()) {
|
||||
throw ValidationException::withMessages([
|
||||
'billingName' => __('Bitte füllen Sie für eine Rechnungsadresse Name, Adresse, PLZ, Ort und Land aus.'),
|
||||
]);
|
||||
// USt-ID: hartes Format-Gate; die Online-Bestätigung (eVatR) bleibt
|
||||
// ein Hinweis und blockiert das Speichern nicht.
|
||||
if (filled($validated['taxIdNumber'])) {
|
||||
$this->refreshVatCheck();
|
||||
|
||||
if ($this->vatCheckStatus === VatIdCheckStatus::FormatInvalid->value) {
|
||||
$this->addError('taxIdNumber', (string) $this->vatCheckMessage);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
|
|
@ -135,9 +230,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
$user->billingAddress()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'salutation_key' => $validated['salutationKey'] !== 'none' ? $validated['salutationKey'] : null,
|
||||
'title' => $validated['title'] ?: null,
|
||||
'name' => $validated['billingName'],
|
||||
'salutation_key' => $validated['billingSalutationKey'] !== 'none' ? $validated['billingSalutationKey'] : null,
|
||||
'company' => $validated['billingCompany'] ?: null,
|
||||
'first_name' => $validated['billingFirstName'] ?: null,
|
||||
'last_name' => $validated['billingLastName'] ?: null,
|
||||
// Zusammengesetzte Empfängerzeile für Rechnungs-Snapshots.
|
||||
'name' => trim($validated['billingFirstName'].' '.$validated['billingLastName']),
|
||||
'address1' => $validated['billingAddress1'],
|
||||
'address2' => $validated['billingAddress2'] ?: null,
|
||||
'postal_code' => $validated['billingPostalCode'],
|
||||
|
|
@ -170,7 +268,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public function billingHasInput(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
return filled($this->billingCompany)
|
||||
|| filled($this->billingFirstName)
|
||||
|| filled($this->billingLastName)
|
||||
|| filled($this->billingAddress1)
|
||||
|| filled($this->billingAddress2)
|
||||
|| filled($this->billingPostalCode)
|
||||
|
|
@ -179,7 +279,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
|
||||
public function billingIsComplete(): bool
|
||||
{
|
||||
return filled($this->billingName)
|
||||
return filled($this->billingLastName)
|
||||
&& filled($this->billingAddress1)
|
||||
&& filled($this->billingPostalCode)
|
||||
&& filled($this->billingCity)
|
||||
|
|
@ -191,7 +291,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="page-header">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
|
||||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Profil') }}</span>
|
||||
</div>
|
||||
|
|
@ -199,7 +299,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
{{ __('Mein Profil') }}
|
||||
</h1>
|
||||
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
|
||||
{{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
|
||||
{{ __('Persönliche Daten, Rechnungsadresse und Konto-Einstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
|
|
@ -217,94 +317,44 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('checkout-notice'))
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">{{ session('checkout-notice') }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="saveProfile" class="space-y-6">
|
||||
<article class="panel" id="rechnungsadresse">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
||||
@if ($billingComplete)
|
||||
<span class="badge ok dot">{{ __('vollständig') }}</span>
|
||||
@else
|
||||
<span class="badge warn dot">{{ __('unvollständig') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div class="space-y-3">
|
||||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }}
|
||||
</p>
|
||||
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }}
|
||||
</p>
|
||||
|
||||
@if (! $billingComplete)
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingAddress1" :label="__('Adresse Zeile 1')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingAddress2" :label="__('Adresse Zeile 2')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
|
||||
<flux:input wire:model="billingCity" :label="__('Ort')" />
|
||||
<flux:select wire:model="billingCountryCode" :label="__('Land')">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="taxIdNumber" :label="__('USt-ID')" />
|
||||
<flux:error name="billingName" class="sm:col-span-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Rechnungsadresse speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== 1) PERSÖNLICHE DATEN + KONTO ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<article class="panel" id="profil">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Profileinstellungen') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Persönliche Daten') }}</span>
|
||||
</div>
|
||||
<div class="p-5 grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:textarea wire:model="address" :label="__('Adresse')" class="sm:col-span-2" />
|
||||
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Profil speichern') }}
|
||||
</flux:button>
|
||||
<div class="p-5 space-y-4">
|
||||
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Ihre Kontaktdaten für Ansprache und Rückfragen — unabhängig von der Rechnungsadresse unten.') }}
|
||||
</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
|
||||
<flux:select wire:model="salutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
|
||||
<flux:input wire:model="firstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="lastName" :label="__('Nachname')" />
|
||||
<flux:input wire:model="phone" :label="__('Telefon')" />
|
||||
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
|
||||
<flux:textarea wire:model="address" :label="__('Kontaktadresse')" class="sm:col-span-2" />
|
||||
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -327,6 +377,121 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
</article>
|
||||
</div>
|
||||
|
||||
{{-- ============== 2) RECHNUNGSADRESSE ============== --}}
|
||||
<article class="panel" id="rechnungsadresse">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
|
||||
@if ($billingComplete)
|
||||
<span class="badge ok dot">{{ __('vollständig') }}</span>
|
||||
@else
|
||||
<span class="badge warn dot">{{ __('unvollständig') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div class="space-y-3">
|
||||
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Grundlage für Rechnungen und Pflicht für jede Tarif- oder Einzel-PM-Buchung. Die Daten werden bei der Buchung an Stripe übergeben.') }}
|
||||
</p>
|
||||
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Pflichtangaben: Nachname, Straße, PLZ, Ort und Land. Firmenname und USt-ID sind optional.') }}
|
||||
</p>
|
||||
|
||||
@if (! $billingComplete)
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">
|
||||
{{ __('Ohne vollständige Rechnungsadresse können keine Tarife oder Einzel-PMs gebucht werden.') }}
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:button size="sm" variant="filled" icon="arrow-down-on-square" wire:click="copyProfileToBilling">
|
||||
{{ __('Persönliche Daten übernehmen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
|
||||
{{ __('Rechnungsempfänger') }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:select wire:model="billingSalutationKey" :label="__('Anrede')">
|
||||
@foreach ($salutations as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input wire:model="billingCompany" :label="__('Firmenname (optional)')" />
|
||||
<flux:input wire:model="billingFirstName" :label="__('Vorname')" />
|
||||
<flux:input wire:model="billingLastName" :label="__('Nachname')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
|
||||
{{ __('Anschrift') }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<flux:input wire:model="billingAddress1" :label="__('Straße und Hausnummer')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingAddress2" :label="__('Adresszusatz (optional)')" class="sm:col-span-2" />
|
||||
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
|
||||
<flux:input wire:model="billingCity" :label="__('Ort')" />
|
||||
<flux:select wire:model.live="billingCountryCode" :label="__('Land')" class="sm:col-span-2">
|
||||
@foreach ($countries as $code => $name)
|
||||
<option value="{{ $code }}">{{ $name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
|
||||
{{ __('Steuern') }}
|
||||
</div>
|
||||
<flux:input
|
||||
wire:model.live.debounce.600ms="taxIdNumber"
|
||||
:label="__('USt-ID (optional)')"
|
||||
placeholder="DE123456789"
|
||||
:description="__('Für EU-Firmen außerhalb Deutschlands entfällt mit gültiger USt-ID die deutsche Umsatzsteuer (Reverse Charge).')"
|
||||
/>
|
||||
@if ($vatCheckMessage)
|
||||
@if ($vatCheckStatus === 'valid')
|
||||
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-gain-deep)]">
|
||||
<flux:icon.check-circle class="size-[14px] flex-shrink-0 mt-0.5" />
|
||||
<span>{{ $vatCheckMessage }}</span>
|
||||
</div>
|
||||
@elseif (in_array($vatCheckStatus, ['invalid', 'format_invalid'], true))
|
||||
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-err,#b91c1c)]">
|
||||
<flux:icon.x-circle class="size-[14px] flex-shrink-0 mt-0.5" />
|
||||
<span>{{ $vatCheckMessage }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-ink-3)]">
|
||||
<flux:icon.information-circle class="size-[14px] flex-shrink-0 mt-0.5" />
|
||||
<span>{{ $vatCheckMessage }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== 3) EINSTELLUNGEN ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
|
||||
|
|
@ -336,18 +501,18 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
|||
wire:model="showStats"
|
||||
align="right"
|
||||
:label="__('Statistiken anzeigen')"
|
||||
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen.')"
|
||||
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen. Greift mit dem Relaunch der Portal-Seiten.')"
|
||||
/>
|
||||
<flux:switch
|
||||
wire:model="disableFooterCode"
|
||||
align="right"
|
||||
:label="__('Footer-Code deaktivieren')"
|
||||
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren.')"
|
||||
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren. Greift mit dem Relaunch der Portal-Seiten.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
|
||||
<flux:button type="submit" variant="primary">
|
||||
{{ __('Einstellungen speichern') }}
|
||||
{{ __('Speichern') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Enums\SinglePurchaseStatus;
|
||||
use App\Enums\SinglePurchaseType;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\Plan;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
|
|
@ -21,6 +22,9 @@ function checkoutTestCustomer(): User
|
|||
$user = User::factory()->create();
|
||||
$user->assignRole('customer');
|
||||
|
||||
// Buchungs-Voraussetzung (12.06.2026): vollständige Rechnungsadresse.
|
||||
BillingAddress::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +59,26 @@ test('an unknown plan or invalid interval responds 404', function () {
|
|||
$this->get("/admin/me/checkout/abo/{$plan->slug}/weekly")->assertNotFound();
|
||||
});
|
||||
|
||||
test('a checkout without a complete billing address redirects to the profile', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('customer');
|
||||
$plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_addr']);
|
||||
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']))
|
||||
->assertRedirect(route('me.profile'))
|
||||
->assertSessionHas('checkout-notice');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('me.checkout.single-pm'))
|
||||
->assertRedirect(route('me.profile'))
|
||||
->assertSessionHas('checkout-notice');
|
||||
|
||||
expect(SinglePurchase::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a plan without a synced stripe price redirects back with a notice', function () {
|
||||
/** @var TestCase $this */
|
||||
$plan = Plan::factory()->create(['stripe_price_id_monthly' => null]);
|
||||
|
|
|
|||
94
tests/Feature/Billing/VatIdValidationServiceTest.php
Normal file
94
tests/Feature/Billing/VatIdValidationServiceTest.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\VatIdCheckStatus;
|
||||
use App\Services\Billing\VatIdValidationService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function vatIdService(): VatIdValidationService
|
||||
{
|
||||
return app(VatIdValidationService::class);
|
||||
}
|
||||
|
||||
test('a malformed vat id fails the format check', function () {
|
||||
$result = vatIdService()->check('123-nicht-gueltig');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::FormatInvalid);
|
||||
});
|
||||
|
||||
test('a vat id must match the billing country', function () {
|
||||
$result = vatIdService()->check('ATU12345678', 'DE');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::FormatInvalid)
|
||||
->and($result['message'])->toContain('passt nicht zum Land');
|
||||
});
|
||||
|
||||
test('greek vat ids use the EL prefix and pass for country GR', function () {
|
||||
$result = vatIdService()->check('EL123456789', 'GR');
|
||||
|
||||
expect($result['status'])->not->toBe(VatIdCheckStatus::FormatInvalid);
|
||||
});
|
||||
|
||||
test('german vat ids are format checked but not confirmed online', function () {
|
||||
config()->set('billing.own_vat_id', 'DE999999999');
|
||||
|
||||
Http::fake();
|
||||
|
||||
$result = vatIdService()->check('DE123456789', 'DE');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::Unverified);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('without an own vat id the check stays a format check', function () {
|
||||
config()->set('billing.own_vat_id', null);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$result = vatIdService()->check('ATU12345678', 'AT');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::Unverified)
|
||||
->and($result['message'])->toContain('nicht konfiguriert');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a foreign eu vat id is confirmed via the evatr api', function () {
|
||||
config()->set('billing.own_vat_id', 'DE999999999');
|
||||
|
||||
Http::fake([
|
||||
'api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-0000']),
|
||||
]);
|
||||
|
||||
$result = vatIdService()->check('ATU12345678', 'AT');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::Valid);
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
return $request['anfragendeUstid'] === 'DE999999999'
|
||||
&& $request['angefragteUstid'] === 'ATU12345678';
|
||||
});
|
||||
});
|
||||
|
||||
test('a rejected evatr status marks the vat id as invalid', function () {
|
||||
config()->set('billing.own_vat_id', 'DE999999999');
|
||||
|
||||
Http::fake([
|
||||
'api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-1001']),
|
||||
]);
|
||||
|
||||
$result = vatIdService()->check('FRXX999999999', 'FR');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::Invalid)
|
||||
->and($result['message'])->toContain('evatr-1001');
|
||||
});
|
||||
|
||||
test('an unreachable evatr api degrades to unverified', function () {
|
||||
config()->set('billing.own_vat_id', 'DE999999999');
|
||||
|
||||
Http::fake([
|
||||
'api.evatr.vies.bzst.de/*' => Http::response(null, 503),
|
||||
]);
|
||||
|
||||
$result = vatIdService()->check('NL123456789B01', 'NL');
|
||||
|
||||
expect($result['status'])->toBe(VatIdCheckStatus::Unverified);
|
||||
});
|
||||
|
|
@ -32,12 +32,14 @@ test('customer can update own profile fields', function () {
|
|||
->set('language', 'en')
|
||||
->set('address', 'Musterfirma GmbH, Musterstrasse 1, 10115 Berlin')
|
||||
->set('countryCode', 'AT')
|
||||
->set('billingName', 'Musterfirma GmbH')
|
||||
->set('billingCompany', 'Musterfirma GmbH')
|
||||
->set('billingFirstName', 'Max')
|
||||
->set('billingLastName', 'Mustermann')
|
||||
->set('billingAddress1', 'Musterstrasse 1')
|
||||
->set('billingPostalCode', '10115')
|
||||
->set('billingCity', 'Berlin')
|
||||
->set('billingCountryCode', 'DE')
|
||||
->set('taxIdNumber', 'ATU12345678')
|
||||
->set('taxIdNumber', 'DE123456789')
|
||||
->set('showStats', true)
|
||||
->call('saveProfile')
|
||||
->assertHasNoErrors();
|
||||
|
|
@ -50,13 +52,59 @@ test('customer can update own profile fields', function () {
|
|||
expect($customer->profile?->last_name)->toBe('Mustermann');
|
||||
expect($customer->profile?->address)->toBe('Musterfirma GmbH, Musterstrasse 1, 10115 Berlin');
|
||||
expect($customer->profile?->country_code)->toBe('AT');
|
||||
expect($customer->profile?->tax_id_number)->toBe('ATU12345678');
|
||||
expect($customer->profile?->tax_id_number)->toBe('DE123456789');
|
||||
expect($customer->profile?->show_stats)->toBeTrue();
|
||||
expect($customer->billingAddress?->name)->toBe('Musterfirma GmbH');
|
||||
expect($customer->billingAddress?->company)->toBe('Musterfirma GmbH');
|
||||
expect($customer->billingAddress?->first_name)->toBe('Max');
|
||||
expect($customer->billingAddress?->last_name)->toBe('Mustermann');
|
||||
expect($customer->billingAddress?->name)->toBe('Max Mustermann');
|
||||
expect($customer->billingAddress?->address1)->toBe('Musterstrasse 1');
|
||||
expect($customer->billingAddress?->postal_code)->toBe('10115');
|
||||
expect($customer->billingAddress?->city)->toBe('Berlin');
|
||||
expect($customer->billingAddress?->country_code)->toBe('DE');
|
||||
expect($customer->billingAddress?->vat_id)->toBe('DE123456789');
|
||||
});
|
||||
|
||||
test('an incomplete billing address reports each missing field exactly once', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('billingCompany', 'Nur Firma GmbH')
|
||||
->call('saveProfile')
|
||||
->assertHasErrors(['billingLastName', 'billingAddress1', 'billingPostalCode', 'billingCity']);
|
||||
});
|
||||
|
||||
test('a malformed vat id blocks saving with a field error', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('taxIdNumber', '123-nicht-gueltig')
|
||||
->call('saveProfile')
|
||||
->assertHasErrors(['taxIdNumber']);
|
||||
});
|
||||
|
||||
test('the profile data can be copied into the billing address', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->set('salutationKey', 'mr')
|
||||
->set('firstName', 'Kopier')
|
||||
->set('lastName', 'Kunde')
|
||||
->set('countryCode', 'AT')
|
||||
->call('copyProfileToBilling')
|
||||
->assertSet('billingSalutationKey', 'mr')
|
||||
->assertSet('billingFirstName', 'Kopier')
|
||||
->assertSet('billingLastName', 'Kunde')
|
||||
->assertSet('billingCountryCode', 'AT');
|
||||
});
|
||||
|
||||
test('customer profile keeps company management out of the profile page', function () {
|
||||
|
|
@ -72,7 +120,7 @@ test('customer profile keeps company management out of the profile page', functi
|
|||
|
||||
LivewireVolt::test('customer.profile')
|
||||
->assertSee('Rechnungsadresse')
|
||||
->assertSee('Profileinstellungen')
|
||||
->assertSee('Persönliche Daten')
|
||||
->assertSee('Firmen verwalten')
|
||||
->assertDontSee('Zugeordnete Firmen')
|
||||
->assertDontSee('Nicht im Profil geladene Firma');
|
||||
|
|
|
|||
34
tests/Feature/PressReleaseCreateCompanyGuardTest.php
Normal file
34
tests/Feature/PressReleaseCreateCompanyGuardTest.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('the pm create page shows a notice instead of the form without a company', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->assertSet('hasCompanies', false)
|
||||
->assertSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
|
||||
->assertSee('Firma anlegen')
|
||||
->assertDontSee('Zur Prüfung senden');
|
||||
});
|
||||
|
||||
test('the pm create page shows the editor when a company exists', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$customer->companies()->attach($company->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.create')
|
||||
->assertSet('hasCompanies', true)
|
||||
->assertSet('companyId', $company->id)
|
||||
->assertDontSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
|
||||
->assertSee('Zur Prüfung senden');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue