requireConfirmedPassword(); app(EnableTwoFactorAuthentication::class)(auth()->user()); } /** Accept a code from the app, which is what actually turns it on. */ public function confirmTwoFactor(): void { $this->requireConfirmedPassword(); try { app(ConfirmTwoFactorAuthentication::class)( auth()->user(), $this->twoFactorCode, ); } catch (ValidationException $e) { $this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_code_wrong')); return; } $this->twoFactorCode = ''; // Shown once, here, because this is the only moment they exist in a // form anyone will read. They stay retrievable afterwards, but nobody // writes down what they were not shown. $this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true); $this->dispatch('notify', message: __('settings.twofa_on')); } public function regenerateRecoveryCodes(): void { $this->requireConfirmedPassword(); app(GenerateNewRecoveryCodes::class)(auth()->user()); $this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true); } public function disableTwoFactor(): void { $this->requireConfirmedPassword(); app(DisableTwoFactorAuthentication::class)(auth()->user()); $this->recoveryCodes = null; $this->dispatch('notify', message: __('settings.twofa_off')); } /** * The disable button opens ConfirmDisableTwoFactor instead of calling * disableTwoFactor() directly (R23); its confirm button dispatches back * here, and disableTwoFactor() still runs its own password check. */ #[On('twofa-disable-confirmed')] public function onTwoFaDisableConfirmed(): void { $this->disableTwoFactor(); } /** * Every two-factor action goes through this. * * Server-side, on each call, and not merely by hiding the buttons: a * Livewire action is reachable by anyone who can post to /livewire/update, * and "the form was not on screen" has never stopped anybody. */ private function requireConfirmedPassword(): void { abort_unless($this->passwordRecentlyConfirmed(), 403); } use ResolvesCustomer, WithFileUploads; // Company / billing profile #[Validate('required|string|max:255')] public string $companyName = ''; #[Validate('nullable|string|max:255')] public string $contactName = ''; #[Validate('nullable|string|max:64')] public string $phone = ''; /** * Consumer or business — the answer given at sign-up, correctable here. * * Empty means nobody has ever recorded one, which is a real state and not a * third kind of customer: every customer created from a Stripe event under * an address they did not register with arrives that way. Shown so it can * be answered, and never silently filled in from the VAT field beneath it. */ #[Validate('nullable|string|max:16')] public string $customerType = ''; #[Validate('nullable|string|max:64')] public string $vatId = ''; /** * The billing address, field by field. * * It was one textarea, so what landed in it was whatever somebody typed — a * postcode on the street line, a city with no postcode, no country at all. * An invoice has to name its recipient precisely enough to be a document. * * `customers.billing_address` still exists and is written from these on * every save: everything that already reads it (IssueInvoice, the console's * customer page) keeps working, and a record nobody has re-saved keeps the * block it always had rather than being guessed apart. */ #[Validate('nullable|string|max:255')] public string $billingStreet = ''; #[Validate('nullable|string|max:32')] public string $billingPostcode = ''; #[Validate('nullable|string|max:255')] public string $billingCity = ''; #[Validate('nullable|string|max:255')] public string $billingCountry = ''; // Branding #[Validate('nullable|string|max:255')] public string $brandDisplayName = ''; #[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')] public string $brandPrimary = ''; #[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')] public string $brandAccent = ''; /** New logo upload (validated on save). */ public $logo = null; public ?string $brandLogoPath = null; public function mount(): void { // A tab name out of the URL is a string a stranger typed. Anything // unknown falls back to the first rather than rendering a settings page // with no section on it at all. if (! in_array($this->tab, self::TABS, true)) { $this->tab = self::TABS[0]; } $c = $this->customer(); if ($c === null) { return; } $this->companyName = $c->name ?? ''; $this->contactName = $c->contact_name ?? ''; $this->phone = $c->phone ?? ''; $this->customerType = $c->customer_type ?? ''; $this->vatId = $c->vat_id ?? ''; $this->billingStreet = $c->billing_street ?? ''; $this->billingPostcode = $c->billing_postcode ?? ''; $this->billingCity = $c->billing_city ?? ''; $this->billingCountry = $c->billing_country ?? ''; $this->brandDisplayName = $c->brand_display_name ?? ''; $this->brandPrimary = $c->brand_primary_color ?? ''; $this->brandAccent = $c->brand_accent_color ?? ''; $this->brandLogoPath = $c->brand_logo_path; } public function saveProfile(): void { $c = $this->requireCustomer(); if ($c === null) { return; } $this->validateOnly('companyName'); $data = $this->validate([ 'companyName' => 'required|string|max:255', 'contactName' => 'nullable|string|max:255', 'phone' => 'nullable|string|max:64', // Only one of the two real answers may be stored. An empty string // leaves the record as it was — the form can be saved by somebody // who never touched this field — but no third value can ever get in. 'customerType' => ['nullable', Rule::in(Customer::TYPES)], 'vatId' => 'nullable|string|max:64', 'billingStreet' => 'nullable|string|max:255', 'billingPostcode' => 'nullable|string|max:32', 'billingCity' => 'nullable|string|max:255', 'billingCountry' => 'nullable|string|max:255', ]); $c->update([ 'name' => $data['companyName'], 'contact_name' => $data['contactName'] ?: null, 'phone' => $data['phone'] ?: null, // FIXED once it is on record, and this is the line that fixes it. // // It decides the fourteen-day right of withdrawal, and a business // that could set itself to "Privatperson" here would be a business // that can withdraw from a contract it has no right to withdraw // from. That is why registration asks the question and why this form // cannot answer it a second time. A record that has none — created // before the question existed — may still be given one, once. 'customer_type' => $c->customer_type ?: ($data['customerType'] ?: null), 'vat_id' => $data['vatId'] ?: null, 'billing_street' => $data['billingStreet'] ?: null, 'billing_postcode' => $data['billingPostcode'] ?: null, 'billing_city' => $data['billingCity'] ?: null, 'billing_country' => $data['billingCountry'] ?: null, // The composed block, rewritten from the fields. Everything that // reads an address today reads this one. 'billing_address' => self::composeAddress($data) ?: $c->billing_address, ]); $this->dispatch('notify', message: $this->verifyVatId($c)); } /** * The four fields as the block every reader already expects. * * Street, then postcode and city on one line, then the country — the shape * an address takes on an Austrian invoice. Empty in, empty out: a customer * who has filled in nothing must not be given a document with two blank * lines and a comma on it. * * @param array $data */ public static function composeAddress(array $data): string { $city = trim(trim((string) ($data['billingPostcode'] ?? '')).' '.trim((string) ($data['billingCity'] ?? ''))); $lines = array_filter([ trim((string) ($data['billingStreet'] ?? '')), $city, trim((string) ($data['billingCountry'] ?? '')), ], fn (string $line) => $line !== ''); return implode("\n", $lines); } /** * Check the number against the register, and say what came back. * * Here, at the moment it is entered, because that is the only moment somebody * is looking at the field and can correct a typo. Reverse charge is only * lawful against a number that is actually registered, so an unchecked number * is charged the domestic rate — which means an EU business that mistyped * theirs pays VAT it cannot reclaim at home until somebody notices. * * The save has already happened by the time this runs and cannot be undone by * it. A register that is down must not cost the customer their address change, * and a number the register rejects is still the number they typed — theirs to * correct, not ours to discard. */ private function verifyVatId(Customer $customer): string { $check = app(VerifyCustomerVatId::class)($customer); if ($check === null) { return __('settings.profile_saved'); } if ($check->isValid()) { return __('settings.vat_verified'); } // Told apart on purpose. "We could not check right now" is our problem and // resolves itself on the next sweep; "the register does not know this // number" is something only the customer can fix. return __($check->isUnavailable() ? 'settings.vat_unchecked' : 'settings.vat_unknown'); } public function saveBranding(): void { $c = $this->requireCustomer(); if ($c === null) { return; } $this->validate([ 'brandDisplayName' => 'nullable|string|max:255', 'brandPrimary' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', 'brandAccent' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/', 'logo' => 'nullable|image|mimes:png,webp|max:2048', ]); // Store the new upload, but keep the old file until the DB row that // references it is updated — delete the old one only after that commits, // so a failed update never orphans a file or dangles a reference. $oldToDelete = null; if ($this->logo !== null) { $oldToDelete = $this->brandLogoPath; $this->brandLogoPath = $this->logo->store('branding', 'public'); $this->logo = null; } $c->update([ 'brand_display_name' => $this->brandDisplayName ?: null, 'brand_primary_color' => $this->brandPrimary ?: null, 'brand_accent_color' => $this->brandAccent ?: null, 'brand_logo_path' => $this->brandLogoPath, ]); if ($oldToDelete !== null && $oldToDelete !== $this->brandLogoPath) { Storage::disk('public')->delete($oldToDelete); } $this->dispatch('notify', message: __('settings.branding_saved')); } /** * The consumer exercises the fourteen-day right of withdrawal. * * Raised by ConfirmWithdraw rather than called from the card (R23): this * ends the service the same afternoon and cannot be taken back, so it is * confirmed in a dialog this product draws. * * Every check that matters is on the server, inside WithdrawContract, and * not in the markup that leads here. A Livewire action is reachable by * anybody who can POST to /livewire/update — a business customer who has * never seen this card can still name the method — so the refusal is made * where the mutation is, once, in the customer's own words. */ /** * Accept the processing agreement in force. * * One press, no dialogue: this is an agreement, not a deletion, and putting * a confirmation in front of it would only make the record harder to obtain. * The evidence — the moment, the address, the login — is taken by the * service, so nothing here can record an acceptance from an address it * invented. */ public function acceptProcessingAgreement(): void { $c = $this->requireCustomer(); if ($c === null) { return; } if (app(ProcessingAgreement::class)->accept($c) === null) { // Nothing published yet. The card does not render in that case, so // reaching this line means somebody posted to it directly. return; } $this->dispatch('notify', message: __('dpa.accepted_notice')); } #[On('withdrawal-confirmed')] public function withdraw(): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $contract = app(CustomDomainAccess::class)->contractOf($customer); if ($contract === null) { $this->dispatch('notify', message: __('withdrawal.refusal_no_contract')); return; } try { app(WithdrawContract::class)($contract, WithdrawContract::CHANNEL_PORTAL); } catch (RuntimeException $e) { // The sentence WithdrawalRight refused with, which is already the // one the customer would be shown — a business customer, an expired // window, a second click on a withdrawal that has happened. $this->dispatch('notify', message: $e->getMessage()); return; } $this->dispatch('notify', message: __('withdrawal.done')); } public function removeLogo(): void { $c = $this->requireCustomer(); if ($c === null) { return; } if ($this->brandLogoPath !== null) { Storage::disk('public')->delete($this->brandLogoPath); } $this->brandLogoPath = null; $c->update(['brand_logo_path' => null]); $this->dispatch('notify', message: __('settings.branding_saved')); } #[Layout('layouts.portal-app')] public function render() { $c = $this->customer(); // Prefer the active/cancelling instance for the package section so the // controls line up with what cancellation actually targets. $active = $c?->instances()->where('status', 'active')->latest('id')->first(); $scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first(); $instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first(); $user = auth()->user(); // Resolved once here so the card and the dialog it opens cannot state // different deadlines for the same contract. A business customer gets // `applies = false` and the card never renders — but the refusal that // matters is the one in withdraw(), on the server. $contract = app(CustomDomainAccess::class)->contractOf($c); $withdrawal = WithdrawalRight::for($contract); return view('livewire.settings', [ 'tabs' => self::TABS, // Never the secret itself — only whether it exists, and the SVG // Fortify renders from it. The secret in a Livewire property would // travel to the browser and back in the component snapshot. 'twoFactorPending' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null, 'twoFactorOn' => $user->two_factor_confirmed_at !== null, 'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null ? $user->twoFactorQrCodeSvg() : null, 'passwordConfirmed' => $this->passwordRecentlyConfirmed(), 'customer' => $c, 'instance' => $instance, 'branding' => $c?->brandingResolved(), 'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null, 'hasActivePackage' => $active !== null, 'cancellationScheduled' => $active === null && $scheduled !== null, 'withdrawal' => $withdrawal, // Only the two answers, so the form cannot offer "unrecorded" as a // thing somebody can choose. An unrecorded customer sees neither // selected, which is the truth about their record. 'customerTypes' => Customer::TYPES, // The processing agreement in force and this customer's acceptance // of THAT version — an acceptance of a superseded one is history, // not an answer (see ProcessingAgreement). 'dpa' => ($dpaService = app(ProcessingAgreement::class))->current(), 'dpaAcceptance' => $dpaService->acceptanceOf($c), ]); } }