Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not owned by user no-reply@…".** Every purpose mailbox here has its own SMTP account, and a mail server lets an account send only from the address it owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the mailable asked for, so a mail addressed support@ went out over the no-reply@ login. It sends through the mailable's own mailer now, and the cloud-ready preview — the one mailable with no mailbox at all — takes the provisioning mailbox like the real notification does. Writing the test for that found the same bug in production code: **InvoiceMail and OrderConfirmationMail set their From to billing@ and never named a mailer**, so both went out over mail.default's no-reply@ login. On this installation no customer had ever received an invoice mail or an order confirmation; they rendered perfectly, queued without complaint and were refused at the door. MailSenderOwnershipTest now scans for the mismatch: a mail that takes a mailbox From must use that mailbox's mailer. **The box on the order page accepts the terms now**, not an immediate start. It used to carry the whole FAGG §16 sentence, which read like a choice between "now" and "in fourteen days" — and there is no second option. The terms are what regulate the sale, so they had to exist: resources/views/legal/terms.blade.php replaces the placeholder with fourteen sections written from what this software actually does — the delivery, the capacity queue, the full refund on withdrawal, the cancellation at period end, the deletion deadlines. The company data comes from CompanyProfile, so the page and the invoices cannot drift. No availability figure and no liability cap has been invented. No order goes through without it: the button is unusable until the box is ticked and says why, and CheckoutController still refuses server-side — the browser half refuses nothing. The request field is `terms_accepted`; the Stripe metadata key stays `immediate_start`, because a session opened before a deploy is paid after it and the webhook would find nothing under a new name. **"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one. That was the whole of what we said, so the answer looked like yes. The second rule now exists and is stated: PruneDormantAccounts removes a confirmed account after a year when it never had a package — no customer record at all, which is where every order, contract and seven-year invoice hangs. A fortnight's notice goes out first, once, and `dormant_warned_at` is what permits the deletion: an account whose warning never went out is never removed. Signing in resets the clock, measured off the device rows because users has no last_login_at. Both deadlines are said in the portal settings, on the verification page, and in the terms — each reading the number off the command that enforces it. Also: the wordmark scan matched any element whose text merely BEGINS with the company name, which a paragraph of terms does. It looks for the lockup form now (no whitespace after the tag), which is what it was always about. The seven checkout tests in the parallel session's files were posting the old field name and now post the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feature/betriebsmodus^2
parent
4ac9fb1d03
commit
fed4acf31c
|
|
@ -0,0 +1,206 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\DormantAccountWarningMail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A confirmed account that has never had a package, removed after a year.
|
||||
*
|
||||
* The other sweep (PruneUnverifiedAccounts) is about registrations nobody
|
||||
* confirmed and takes five days. This one is about the opposite case: somebody
|
||||
* confirmed their address, looked around, never bought anything, and never came
|
||||
* back. A year later there is a login, an address and a password hash on file
|
||||
* for no purpose at all — which is a data-protection question, not tidiness.
|
||||
*
|
||||
* ## What "no package" means, and why it is drawn this wide
|
||||
*
|
||||
* No instance, no contract, no order, no invoice — ever, not merely "not now".
|
||||
* A customer who cancelled last month has invoices, and invoices have to be kept
|
||||
* for seven years (§ 132 BAO). Their login is part of how they reach those
|
||||
* invoices, so nothing with commercial history is touched here. Both links
|
||||
* between `users` and `customers` count, by id and by address, for the same
|
||||
* reason as in the other sweep.
|
||||
*
|
||||
* ## The clock is reset by signing in
|
||||
*
|
||||
* Measured from the LATEST of the registration and the last time any device was
|
||||
* seen (`user_devices.last_seen_at`) — so a person who signs in once a year
|
||||
* keeps their account, which is what the terms promise. There is no
|
||||
* `last_login_at` on users, and the device rows already carry exactly this.
|
||||
*
|
||||
* ## Nobody is deleted without being told
|
||||
*
|
||||
* A warning goes out a fortnight before, once, and `dormant_warned_at` records
|
||||
* it. An account is only removed when that warning is on file and old enough —
|
||||
* so a run that has never warned anybody deletes nobody, and a mail server that
|
||||
* was down that night delays the deletion instead of skipping the warning.
|
||||
*/
|
||||
class PruneDormantAccounts extends Command
|
||||
{
|
||||
/** How long an account with no package is kept. */
|
||||
public const AFTER_DAYS = 365;
|
||||
|
||||
/** How long before that the warning goes out. */
|
||||
public const WARN_DAYS_BEFORE = 14;
|
||||
|
||||
protected $signature = 'clupilot:prune-dormant {--dry-run : Only say what would happen}';
|
||||
|
||||
protected $description = 'Warn, then delete confirmed accounts that never had a package';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dry = (bool) $this->option('dry-run');
|
||||
|
||||
$warned = $this->sendWarnings($dry);
|
||||
$removed = $this->remove($dry);
|
||||
|
||||
$this->info($dry
|
||||
? "would warn {$warned}, would remove {$removed}."
|
||||
: "{$warned} warned, {$removed} removed.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fortnight's notice.
|
||||
*
|
||||
* Not called warn(): Command::warn() is the framework's "print a warning
|
||||
* line" and overriding it with something private breaks the class outright.
|
||||
*/
|
||||
private function sendWarnings(bool $dry): int
|
||||
{
|
||||
$due = $this->dormant()
|
||||
->whereNull('dormant_warned_at')
|
||||
->get()
|
||||
->filter(fn (User $user) => $this->dormantSince($user)
|
||||
->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)));
|
||||
|
||||
foreach ($due as $user) {
|
||||
if ($dry) {
|
||||
$this->line('would warn: '.$user->email);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($user->email)->queue(new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE));
|
||||
} catch (Throwable $e) {
|
||||
// Not stamped: the stamp is what permits the deletion, and
|
||||
// stamping a mail that never went out would delete an account
|
||||
// whose owner was never told.
|
||||
Log::warning('Could not warn a dormant account', [
|
||||
'email' => $user->email,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$user->forceFill(['dormant_warned_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $due->count();
|
||||
}
|
||||
|
||||
/** The deletion itself, a fortnight after the warning at the earliest. */
|
||||
private function remove(bool $dry): int
|
||||
{
|
||||
$due = $this->dormant()
|
||||
->whereNotNull('dormant_warned_at')
|
||||
->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE))
|
||||
->get()
|
||||
->filter(fn (User $user) => $this->dormantSince($user)
|
||||
->lte(now()->subDays(self::AFTER_DAYS)));
|
||||
|
||||
foreach ($due as $user) {
|
||||
if ($dry) {
|
||||
$this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Logged individually rather than as a count: an address that
|
||||
// disappears is the kind of thing somebody asks about later, and a
|
||||
// number in a log answers nothing.
|
||||
Log::info('Removing a dormant account', [
|
||||
'email' => $user->email,
|
||||
'registered_at' => $user->created_at?->toIso8601String(),
|
||||
'warned_at' => $user->dormant_warned_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
$user->delete();
|
||||
}
|
||||
|
||||
return $due->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed, and with no customer record behind it at all.
|
||||
*
|
||||
* Drawn at the customer record rather than at "has no live package on
|
||||
* record", and deliberately wider than it has to be:
|
||||
*
|
||||
* - A customer record means somebody in the business knows this person —
|
||||
* an operator entered them, or a checkout created them. Removing the
|
||||
* login of a record we keep is half a deletion.
|
||||
* - Anything commercial (an order, a contract, an instance, an invoice)
|
||||
* hangs off a customer record, and invoices have to be kept for seven
|
||||
* years (§ 132 BAO). Skipping every customer record skips all of that
|
||||
* without having to enumerate it and without a new table sneaking past
|
||||
* the enumeration later.
|
||||
*
|
||||
* What is left is exactly the case this exists for: somebody registered,
|
||||
* confirmed their address, never bought anything, and never came back.
|
||||
* Registration alone creates no customer record.
|
||||
*
|
||||
* Both links count, by id and by address — see Customer::emailTaken for why
|
||||
* one can exist without the other.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder<User>
|
||||
*/
|
||||
private function dormant()
|
||||
{
|
||||
return User::query()
|
||||
->whereNotNull('email_verified_at')
|
||||
// Old enough to be worth looking at. The precise cut-off is applied
|
||||
// per user afterwards, because it depends on the devices — and this
|
||||
// one is <=, not <: a timestamp exactly at the threshold is stored to
|
||||
// the second, and a stricter pre-filter would drop the very account
|
||||
// the per-user check was about to catch.
|
||||
->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))
|
||||
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
|
||||
->whereColumn('customers.user_id', 'users.id'))
|
||||
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
|
||||
->whereColumn('customers.email', 'users.email'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Since when this account has been doing nothing.
|
||||
*
|
||||
* The later of the registration and the last time any of its devices was
|
||||
* seen: signing in resets the clock, which is what the terms say.
|
||||
*/
|
||||
private function dormantSince(User $user): Carbon
|
||||
{
|
||||
// Scoped by guard as well as by id: operator 1 is not customer 1 (R21),
|
||||
// and there is no devices() relation on User to hide that behind.
|
||||
$lastSeen = UserDevice::query()
|
||||
->where('guard', 'web')
|
||||
->where('authenticatable_id', $user->id)
|
||||
->max('last_seen_at');
|
||||
|
||||
$since = $user->created_at ?? now();
|
||||
|
||||
return $lastSeen === null
|
||||
? $since
|
||||
: Carbon::parse($lastSeen)->max($since);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,16 +53,19 @@ class CheckoutController extends Controller
|
|||
$data = $request->validate([
|
||||
'plan' => ['required', 'string', 'max:64'],
|
||||
'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
|
||||
// The consumer's express request that we begin inside the
|
||||
// fourteen-day withdrawal window (FAGG §16, §356 BGB). Required,
|
||||
// because the cloud starts being built the moment the payment
|
||||
// lands: without this on record a withdrawal on day thirteen gets
|
||||
// the WHOLE amount back, however many days the machine actually
|
||||
// ran. A seller who cannot prove the consent was given cannot
|
||||
// charge for the days — and the proof is a timestamp taken here.
|
||||
'immediate_start' => ['accepted'],
|
||||
// Acceptance of the terms, which is where everything about this
|
||||
// sale is regulated: the delivery, the price, the fourteen-day
|
||||
// withdrawal right, and the consumer's express request that we
|
||||
// begin inside that window (FAGG §16, §356 BGB) — a cloud is built
|
||||
// the moment the payment lands, so that request cannot be avoided
|
||||
// and is stated on the checkbox as well as in the terms.
|
||||
//
|
||||
// Required. An order cannot be submitted without it: the box is the
|
||||
// declaration, and the browser only disables the button — this is
|
||||
// the line that actually refuses.
|
||||
'terms_accepted' => ['accepted'],
|
||||
], [
|
||||
'immediate_start.accepted' => __('checkout.immediate_start_required'),
|
||||
'terms_accepted.accepted' => __('checkout.terms_required'),
|
||||
]);
|
||||
|
||||
$term = $data['term'] ?? Subscription::TERM_MONTHLY;
|
||||
|
|
@ -156,6 +159,13 @@ class CheckoutController extends Controller
|
|||
// than being stamped here — the contract only exists once
|
||||
// the payment does, and a consent recorded for a checkout
|
||||
// somebody abandoned would be a consent to nothing.
|
||||
//
|
||||
// The KEY keeps its old name deliberately. The form field it
|
||||
// came from is called terms_accepted now, but a checkout
|
||||
// session opened before a deploy is paid after it, and the
|
||||
// webhook would find nothing under a new name. What it
|
||||
// records is unchanged: the customer declared, at this
|
||||
// moment, that we may start at once.
|
||||
'immediate_start' => '1',
|
||||
// What part of Stripe's amount_total is the setup fee, so the
|
||||
// webhook can tell the two apart again: the package's charge
|
||||
|
|
|
|||
|
|
@ -54,7 +54,17 @@ class MailPreview extends Component
|
|||
// a failure in a worker's log while the button reported success. The
|
||||
// person pressing it is waiting for the mail, so the send happens in
|
||||
// their request and its failure reaches them.
|
||||
Mail::to($operator->email)->sendNow($mailable);
|
||||
//
|
||||
// Through the mailable's OWN mailer. Mail::to() hands back a pending
|
||||
// mail bound to the DEFAULT mailer, and sendNow() then ignores the
|
||||
// mailer the mailable asked for — so a support mail addressed
|
||||
// support@ went out over the no-reply@ login and the server answered
|
||||
// "553 Sender address rejected: not owned by user". Every purpose
|
||||
// mailbox here has its own SMTP account, and the From has to belong
|
||||
// to the account that authenticates.
|
||||
Mail::mailer($mailable->mailer ?: null)
|
||||
->to($operator->email)
|
||||
->sendNow($mailable);
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: __('mail_preview.failed', [
|
||||
'reason' => mb_substr($e->getMessage(), 0, 160),
|
||||
|
|
@ -70,8 +80,22 @@ class MailPreview extends Component
|
|||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$previews = app(MailPreviews::class)->all();
|
||||
|
||||
return view('livewire.admin.mail-preview', [
|
||||
'previews' => app(MailPreviews::class)->all(),
|
||||
'previews' => $previews,
|
||||
// Read off the mailables rather than kept as a second list: the
|
||||
// sender is decided by the purpose mailbox, and a table here would
|
||||
// be a copy that drifts.
|
||||
// From the envelope, not from the mailable's `from` property: that
|
||||
// property stays empty until the mail is built, so reading it here
|
||||
// showed nothing at all.
|
||||
'senders' => collect($previews)
|
||||
->keys()
|
||||
->mapWithKeys(fn (string $key) => [
|
||||
$key => app(MailPreviews::class)->make($key)?->envelope()->from?->address,
|
||||
])
|
||||
->all(),
|
||||
'me' => Auth::guard('operator')->user()?->email ?? '',
|
||||
// A preview that goes nowhere is worse than none: with a
|
||||
// non-delivering mailer the browser view still works and the send
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\User;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* "Your account will be removed in a fortnight, and here is how to keep it."
|
||||
*
|
||||
* Sent once, a fortnight before an account that has never had a package is
|
||||
* deleted for good (App\Console\Commands\PruneDormantAccounts). Not a marketing
|
||||
* mail and not a nudge to buy something: it says what will happen, when, and that
|
||||
* signing in is enough to stop it.
|
||||
*
|
||||
* From the SYSTEM mailbox — this is about the login, not about money and not
|
||||
* about a running service.
|
||||
*/
|
||||
class DormantAccountWarningMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public User $user, public int $days)
|
||||
{
|
||||
$this->mailer('cp_'.MailPurpose::SYSTEM);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('dormant_mail.subject'));
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'mail.dormant-warning', with: [
|
||||
'name' => (string) $this->user->name,
|
||||
'days' => $this->days,
|
||||
'loginUrl' => route('login'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,16 @@ class InvoiceMail extends Mailable implements ShouldQueue
|
|||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Invoice $invoice, public string $name) {}
|
||||
public function __construct(public Invoice $invoice, public string $name)
|
||||
{
|
||||
// The mailer, alongside the From. A purpose mailbox authenticates with
|
||||
// its OWN account, and a mail server lets an account send only from the
|
||||
// address it owns — so a From of billing@ over the default mailer's
|
||||
// no-reply@ login is answered "553 Sender address rejected: not owned by
|
||||
// user". Setting the From from a mailbox and leaving the mailer at the
|
||||
// default is a mail that renders perfectly and never arrives.
|
||||
$this->mailer('cp_'.MailPurpose::BILLING);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
|
|
|
|||
|
|
@ -30,7 +30,16 @@ class OrderConfirmationMail extends Mailable implements ShouldQueue
|
|||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Order $order, public string $name) {}
|
||||
public function __construct(public Order $order, public string $name)
|
||||
{
|
||||
// The mailer, alongside the From. A purpose mailbox authenticates with
|
||||
// its OWN account, and a mail server lets an account send only from the
|
||||
// address it owns — so a From of billing@ over the default mailer's
|
||||
// no-reply@ login is answered "553 Sender address rejected: not owned by
|
||||
// user". Setting the From from a mailbox and leaving the mailer at the
|
||||
// default is a mail that renders perfectly and never arrives.
|
||||
$this->mailer('cp_'.MailPurpose::BILLING);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
// When we told this account it would be removed for having no
|
||||
// package — see App\Console\Commands\PruneDormantAccounts.
|
||||
'dormant_warned_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
// is_admin itself is dead: nothing reads it any more (see
|
||||
// Operator/the operator guard). The column stays because dropping
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use App\Mail\MaintenanceCancelledMail;
|
|||
use App\Mail\NewDeviceSignInMail;
|
||||
use App\Mail\OperatorMessageMail;
|
||||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Console\Commands\PruneDormantAccounts;
|
||||
use App\Mail\DormantAccountWarningMail;
|
||||
use App\Mail\ResetPasswordMail;
|
||||
use App\Mail\VerifyEmailMail;
|
||||
use App\Models\Customer;
|
||||
|
|
@ -58,6 +60,7 @@ class MailPreviews
|
|||
'maintenance-cancelled' => 'Wartungsfenster abgesagt',
|
||||
'invoice' => 'Rechnung',
|
||||
'new-device' => 'Anmeldung von einem neuen Gerät',
|
||||
'dormant-warning' => 'Konto ohne Paket wird gelöscht',
|
||||
'operator-message' => 'Nachricht aus der Konsole',
|
||||
];
|
||||
}
|
||||
|
|
@ -91,6 +94,7 @@ class MailPreviews
|
|||
'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer),
|
||||
'invoice' => new InvoiceMail($this->invoice($customer), $customer->name),
|
||||
'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'),
|
||||
'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE),
|
||||
'operator-message' => new OperatorMessageMail(
|
||||
$customer,
|
||||
'Zur Datenübernahme',
|
||||
|
|
@ -177,11 +181,21 @@ class MailPreviews
|
|||
{
|
||||
$mail = new class($customer) extends Mailable
|
||||
{
|
||||
public function __construct(public Customer $customer) {}
|
||||
use \App\Mail\Concerns\SendsFromMailbox;
|
||||
|
||||
public function __construct(public Customer $customer)
|
||||
{
|
||||
// The same mailbox the real notification sends from. Without
|
||||
// this the preview took the framework default — an address no
|
||||
// mailbox owns, over whichever account mail.default logs in
|
||||
// with, which is the "553 Sender address rejected" every
|
||||
// properly configured mail server answers with.
|
||||
$this->mailer('cp_'.MailPurpose::PROVISIONING);
|
||||
}
|
||||
|
||||
public function envelope(): \Illuminate\Mail\Mailables\Envelope
|
||||
{
|
||||
return new \Illuminate\Mail\Mailables\Envelope(subject: __('provisioning.mail.ready_subject'));
|
||||
return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject'));
|
||||
}
|
||||
|
||||
public function content(): \Illuminate\Mail\Mailables\Content
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* An account nobody ever bought anything with is removed after a year — and told
|
||||
* about it first.
|
||||
*
|
||||
* The column is what makes "told about it first" provable. Without it the sweep
|
||||
* would either write the warning every night for the last fortnight, or send it
|
||||
* on exactly one calendar day and lose it whenever that night's run did not
|
||||
* happen. Neither is a promise anybody can keep in an AGB.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('dormant_warned_at')->nullable()->after('email_verified_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('dormant_warned_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -22,11 +22,9 @@ return [
|
|||
// tatsächlich tun, ist eine falsche Angabe — auch wenn sie zu unseren
|
||||
// Lasten ginge. Von der Zustimmung hängt jetzt nichts mehr ab; sie bleibt
|
||||
// als Beleg dafür, dass der Kunde den sofortigen Beginn verlangt hat.
|
||||
'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mein Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalte ich den gesamten gezahlten Betrag zurück.',
|
||||
// Sagt, was ohne die Bestätigung passiert: die Bestellung kommt nicht
|
||||
// zustande. Der Satz stand einmal anders da („dann richten wir erst nach
|
||||
// Ablauf der Frist ein") und versprach damit einen Weg, den es nicht gibt —
|
||||
// eine Cloud wird nach der Zahlung eingerichtet und nicht vierzehn Tage
|
||||
// später.
|
||||
'immediate_start_required' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande — Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet, einen Beginn erst nach vierzehn Tagen gibt es nicht. Ihr Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalten Sie den gesamten Betrag zurück.',
|
||||
// Was ohne das Häkchen passiert: die Bestellung kommt nicht zustande. Der
|
||||
// Satz stand einmal anders da („dann richten wir erst nach Ablauf der Frist
|
||||
// ein") und versprach damit einen Weg, den es nicht gibt — eine Cloud wird
|
||||
// nach der Zahlung eingerichtet und nicht vierzehn Tage später.
|
||||
'terms_required' => 'Ohne Annahme der AGB kommt die Bestellung nicht zustande. Bitte setzen Sie das Häkchen — darin ist auch Ihr 14-tägiges Widerrufsrecht geregelt.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
// Ein Konto, mit dem nie etwas gebucht wurde, wird nach einem Jahr gelöscht.
|
||||
// Diese Nachricht sagt es vorher — einmal, vierzehn Tage davor.
|
||||
return [
|
||||
'subject' => 'Ihr CluPilot-Konto wird in :days Tagen gelöscht',
|
||||
'heading' => 'Ihr Konto wird gelöscht',
|
||||
'preheader' => 'In :days Tagen — anmelden genügt, um das zu verhindern.',
|
||||
'greeting' => 'Guten Tag :name,',
|
||||
'intro' => 'zu Ihrem CluPilot-Konto besteht kein Paket und hat auch nie eines bestanden. Konten ohne Paket löschen wir nach einem Jahr, damit keine unbenutzten Daten bei uns liegen bleiben. Bei Ihrem Konto ist das in :days Tagen der Fall.',
|
||||
'how_to_keep' => 'Wenn Sie das Konto behalten möchten, melden Sie sich einfach einmal an — damit beginnt die Frist neu. Eine Bestellung genügt natürlich ebenso.',
|
||||
'action' => 'Anmelden und Konto behalten',
|
||||
'nothing_lost' => 'Verloren geht dabei nichts außer dem Zugang selbst: Es gibt zu diesem Konto keine Cloud, keine Dateien und keine Rechnungen. Sie können sich später jederzeit neu registrieren.',
|
||||
];
|
||||
|
|
@ -7,10 +7,12 @@ return [
|
|||
'title' => 'Paket buchen',
|
||||
'subtitle' => 'Wählen Sie Ihr Paket. Nach der Zahlung wird Ihre Cloud automatisch aufgesetzt — Sie bekommen die Zugangsdaten per E-Mail und müssen nichts weiter tun.',
|
||||
|
||||
'terms_title' => 'AGB annehmen',
|
||||
'terms_label' => 'Ich habe die :link gelesen und nehme sie an.',
|
||||
'terms_link' => 'Allgemeinen Geschäftsbedingungen',
|
||||
'terms_why' => 'Darin ist alles zu dieser Bestellung geregelt: Preis, Umsatzsteuer, Bereitstellung, Kündigung und Ihr 14-tägiges Widerrufsrecht. Sie verlangen damit auch ausdrücklich, dass die Einrichtung sofort beginnt — sonst könnten wir Ihre Cloud nicht gleich bereitstellen. Widerrufen Sie innerhalb der Frist, erhalten Sie den gesamten Betrag zurück.',
|
||||
'terms_first' => 'Bitte oben die AGB annehmen.',
|
||||
'recommended' => 'Empfohlen',
|
||||
'consent_title' => 'Sofortiger Beginn — bitte bestätigen',
|
||||
'consent_why' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande. Es gibt keinen späteren Beginn: Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet — und Ihr Widerrufsrecht behalten Sie vollständig.',
|
||||
'consent_first' => 'Bitte oben zuerst bestätigen.',
|
||||
'up_to' => 'bis :count',
|
||||
'per_month' => '/Monat',
|
||||
'incl_vat' => 'inkl. :rate % MwSt.',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ return [
|
|||
'cancel_billing_unreachable' => 'Die Kündigung konnte gerade nicht bei unserem Zahlungsdienstleister eingetragen werden. Es wurde nichts geändert — bitte versuchen Sie es in ein paar Minuten erneut.',
|
||||
'keep' => 'Abbrechen',
|
||||
|
||||
'lifecycle_title' => 'Wann wir ein Konto von selbst löschen',
|
||||
'lifecycle_unverified' => 'Eine Registrierung, deren E-Mail-Adresse nicht bestätigt wurde, löschen wir nach :days Tagen. Die Adresse ist danach wieder frei.',
|
||||
'lifecycle_dormant' => 'Ein bestätigtes Konto, zu dem nie ein Paket bestanden hat, löschen wir nach einem Jahr. Wir kündigen das :warn Tage vorher per E-Mail an, und eine Anmeldung genügt, damit die Frist neu beginnt.',
|
||||
'lifecycle_kept' => 'Solange ein Paket läuft, wird nichts automatisch gelöscht. Rechnungen bewahren wir unabhängig davon sieben Jahre auf — dazu sind wir gesetzlich verpflichtet.',
|
||||
'lifecycle_terms' => 'In den AGB nachlesen',
|
||||
|
||||
'close_account_title' => 'Konto schließen',
|
||||
'close_account_sub' => 'Ihr CluPilot-Konto dauerhaft schließen.',
|
||||
'close_cta' => 'Konto schließen',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ return [
|
|||
'notice_delete' => 'Registrierung verwerfen',
|
||||
'notice_wrong_address' => 'Adresse falsch eingetragen? Verwerfen Sie diese Registrierung und legen Sie sie neu an — erst dann ist die Adresse wieder frei. Bestätigen Sie fünf Tage lang nicht, wird der Zugang von selbst gelöscht; Sie müssen dann nichts tun.',
|
||||
|
||||
'notice_after_confirm' => 'Nach der Bestätigung bleibt Ihr Konto bestehen. Nur ein bestätigtes Konto, zu dem nie ein Paket bestanden hat, löschen wir nach einem Jahr — angekündigt per E-Mail, und eine Anmeldung setzt die Frist zurück.',
|
||||
|
||||
'delete_title' => 'Diese Registrierung verwerfen?',
|
||||
'delete_body' => 'Der Zugang wird gelöscht und die E-Mail-Adresse ist wieder frei. Es ist noch nichts eingerichtet und nichts bezahlt — es geht nur diese unbestätigte Anmeldung verloren.',
|
||||
'delete_keep' => 'Behalten',
|
||||
|
|
|
|||
|
|
@ -12,14 +12,13 @@ return [
|
|||
// name for the same charge at the till is when somebody abandons a checkout.
|
||||
'setup_fee_line' => 'Setting up your cloud, one-off',
|
||||
|
||||
// The express request that the service begin at once.
|
||||
// What happens without the tick: no order at all.
|
||||
//
|
||||
// The second sentence used to state a pro-rata liability (FAGG §16). There
|
||||
// is none here any more: a withdrawing consumer gets the whole amount back
|
||||
// (see App\Actions\WithdrawContract), and a tick-box that promises the
|
||||
// customer something worse than what we actually do is a false statement,
|
||||
// even when it is against our own interest. Nothing turns on the consent
|
||||
// now; it stays as a record that the customer asked us to start at once.
|
||||
'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. My right of withdrawal is unaffected: if I withdraw within the period I get the full amount paid back.',
|
||||
'immediate_start_required' => 'Without this confirmation the order cannot go through — your cloud is built as soon as the payment lands, and there is no start fourteen days later. Your right of withdrawal is unaffected: if you withdraw within the period you get the full amount back.',
|
||||
// The box used to carry the whole FAGG §16 sentence, including a pro-rata
|
||||
// liability that does not exist here — a withdrawing consumer gets the whole
|
||||
// amount back (App\Actions\WithdrawContract). It is the terms that regulate
|
||||
// this sale now, and the express request to start at once is stated there and
|
||||
// summarised beside the box. Nothing turns on the record either way; it stays
|
||||
// as proof that the customer asked us to start at once.
|
||||
'terms_required' => 'Without accepting the terms the order cannot go through. Please tick the box — your 14-day right of withdrawal is regulated in there too.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'subject' => 'Your CluPilot account will be deleted in :days days',
|
||||
'heading' => 'Your account will be deleted',
|
||||
'preheader' => 'In :days days — signing in once is enough to stop it.',
|
||||
'greeting' => 'Hello :name,',
|
||||
'intro' => 'there is no package on your CluPilot account and there never has been. We delete accounts without a package after a year, so that no unused data is left lying with us. For your account that happens in :days days.',
|
||||
'how_to_keep' => 'If you would like to keep the account, simply sign in once — that starts the period again. An order does the same, of course.',
|
||||
'action' => 'Sign in and keep the account',
|
||||
'nothing_lost' => 'Nothing is lost but the login itself: there is no cloud, no file and no invoice on this account. You can register again at any time.',
|
||||
];
|
||||
|
|
@ -7,10 +7,12 @@ return [
|
|||
'title' => 'Book a package',
|
||||
'subtitle' => 'Pick your package. Once it is paid your cloud is built automatically — your credentials arrive by mail and there is nothing else for you to do.',
|
||||
|
||||
'terms_title' => 'Accept the terms',
|
||||
'terms_label' => 'I have read and accept the :link.',
|
||||
'terms_link' => 'terms and conditions',
|
||||
'terms_why' => 'They regulate everything about this order: price, VAT, delivery, cancellation and your 14-day right of withdrawal. You are also expressly requesting that we begin at once — otherwise your cloud could not be built straight away. Withdraw within the period and you get the full amount back.',
|
||||
'terms_first' => 'Please accept the terms above.',
|
||||
'recommended' => 'Recommended',
|
||||
'consent_title' => 'Immediate start — please confirm',
|
||||
'consent_why' => 'Without this confirmation the order cannot go through. There is no later start: your cloud is built as soon as the payment lands — and you keep your right of withdrawal in full.',
|
||||
'consent_first' => 'Please confirm above first.',
|
||||
'up_to' => 'up to :count',
|
||||
'per_month' => '/month',
|
||||
'incl_vat' => 'incl. :rate % VAT',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ return [
|
|||
'cancel_billing_unreachable' => 'The cancellation could not be registered with our payment provider just now. Nothing has been changed — please try again in a few minutes.',
|
||||
'keep' => 'Keep',
|
||||
|
||||
'lifecycle_title' => 'When we delete an account by ourselves',
|
||||
'lifecycle_unverified' => 'A registration whose address was never confirmed is deleted after :days days. The address is free again afterwards.',
|
||||
'lifecycle_dormant' => 'A confirmed account that never had a package is deleted after a year. We announce it :warn days beforehand by mail, and signing in once is enough to start the period again.',
|
||||
'lifecycle_kept' => 'While a package is running nothing is deleted automatically. Invoices are kept for seven years regardless — we are required to.',
|
||||
'lifecycle_terms' => 'Read it in the terms',
|
||||
|
||||
'close_account_title' => 'Close account',
|
||||
'close_account_sub' => 'Permanently close your CluPilot account.',
|
||||
'close_cta' => 'Close account',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ return [
|
|||
'notice_delete' => 'Discard this registration',
|
||||
'notice_wrong_address' => 'Typed the wrong address? Discard this registration and start again — only then is the address free. If you do not confirm within five days the account is removed by itself; there is nothing for you to do.',
|
||||
|
||||
'notice_after_confirm' => 'Once confirmed, your account stays. Only a confirmed account that never had a package is deleted after a year — announced by mail, and signing in starts the period again.',
|
||||
|
||||
'delete_title' => 'Discard this registration?',
|
||||
'delete_body' => 'The account is deleted and the address becomes free again. Nothing has been set up and nothing paid for — only this unconfirmed sign-up is lost.',
|
||||
'delete_keep' => 'Keep it',
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
{{--
|
||||
Impressum, Datenschutz, AGB.
|
||||
|
||||
Placeholders still — the texts are the company's to write, not mine to
|
||||
invent. What changed is that they are now pages OF the website rather than
|
||||
a standalone card in a different design: same header, same footer, so
|
||||
somebody following the imprint link from the footer does not land on what
|
||||
looks like a stranded error page.
|
||||
One shell, three pages. Where a partial exists under resources/views/legal/
|
||||
it is included; otherwise the placeholder stands, because those texts are the
|
||||
company's to write and not mine to invent. Pages OF the website rather than a
|
||||
standalone card in a different design: same header, same footer, so somebody
|
||||
following the imprint link from the footer does not land on what looks like a
|
||||
stranded error page.
|
||||
--}}
|
||||
@php($partial = isset($page) ? 'legal.'.$page : null)
|
||||
<x-layouts.site :title="$title.' — CluPilot Cloud'" robots="noindex">
|
||||
|
||||
<article class="mx-auto max-w-[820px] px-5 pb-20 pt-32 sm:px-8 lg:pb-28 lg:pt-40">
|
||||
<p class="lbl">Rechtliches</p>
|
||||
<h1 class="mt-4 text-[clamp(2rem,4vw,3rem)] font-bold leading-[1.08] tracking-[-0.035em] text-ink">{{ $title }}</h1>
|
||||
|
||||
<div class="mt-10 rounded-xl border border-line bg-surface p-8 shadow-xs lg:p-10">
|
||||
<p class="text-md leading-relaxed text-body">Dieser Inhalt wird noch ergänzt.</p>
|
||||
<p class="mt-4 text-md leading-relaxed text-body">
|
||||
Bei Fragen erreichen Sie uns unter
|
||||
<a href="mailto:office@clupilot.com" class="font-medium text-accent-text underline-offset-4 hover:underline">office@clupilot.com</a>.
|
||||
</p>
|
||||
@if ($partial && view()->exists($partial))
|
||||
@include($partial)
|
||||
@else
|
||||
<div class="mt-10 rounded-xl border border-line bg-surface p-8 shadow-xs lg:p-10">
|
||||
<p class="text-md leading-relaxed text-body">Dieser Inhalt wird noch ergänzt.</p>
|
||||
<p class="mt-4 text-md leading-relaxed text-body">
|
||||
Bei Fragen erreichen Sie uns unter
|
||||
<a href="mailto:office@clupilot.com" class="font-medium text-accent-text underline-offset-4 hover:underline">office@clupilot.com</a>.
|
||||
</p>
|
||||
|
||||
<x-ui.button href="{{ route('home') }}" variant="secondary" class="mt-8">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />
|
||||
Zurück zur Startseite
|
||||
</x-ui.button>
|
||||
</div>
|
||||
<x-ui.button href="{{ route('home') }}" variant="secondary" class="mt-8">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />
|
||||
Zurück zur Startseite
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
</x-layouts.site>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
{{--
|
||||
AGB.
|
||||
|
||||
Written from what this software actually does — the checkout, the withdrawal
|
||||
handling in App\Services\Billing\WithdrawalRight, the capacity queue in
|
||||
App\Provisioning\Steps\Customer\ReserveResources, the two account-deletion
|
||||
sweeps in app/Console/Commands, the cancellation in ConfirmCancelPackage.
|
||||
Nothing here promises anything the code does not do, and no availability
|
||||
figure or liability cap has been invented: where the statute already says it,
|
||||
the statute is referred to rather than restated with numbers nobody checked.
|
||||
|
||||
The company data comes from CompanyProfile so this page and the invoices
|
||||
cannot drift apart. Filling in the real register number in the console fixes
|
||||
it here too.
|
||||
|
||||
This is the operator's text, not a lawyer's opinion. It should be read by one
|
||||
before it is relied on in a dispute.
|
||||
--}}
|
||||
@php
|
||||
$company = App\Support\CompanyProfile::all();
|
||||
$vat = rtrim(rtrim(number_format(App\Support\CompanyProfile::taxRate(), 1, ',', '.'), '0'), ',');
|
||||
$setup = App\Support\CompanyProfile::setupFeeCents();
|
||||
$unverifiedDays = App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS;
|
||||
@endphp
|
||||
|
||||
<div class="mt-10 space-y-8 rounded-xl border border-line bg-surface p-8 shadow-xs lg:p-10">
|
||||
|
||||
<p class="text-sm text-muted">Fassung vom {{ now()->local()->isoFormat('D. MMMM YYYY') }}</p>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">1. Vertragspartner und Geltungsbereich</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen
|
||||
{{ $company['name'] }}, {{ $company['address'] }}, {{ $company['postcode'] }} {{ $company['city'] }},
|
||||
{{ $company['country'] }} (im Folgenden „CluPilot“) und ihren Kundinnen und Kunden über die
|
||||
Bereitstellung und den Betrieb von Nextcloud-Instanzen und der dazugehörigen Zusatzleistungen.
|
||||
Die vollständigen Unternehmensdaten stehen im <a href="{{ route('legal.impressum') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">Impressum</a>.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Abweichende Bedingungen des Kunden werden nicht Vertragsinhalt, auch wenn CluPilot ihnen nicht
|
||||
ausdrücklich widerspricht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">2. Leistungsgegenstand</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
CluPilot stellt eine betriebsfertige Nextcloud-Installation auf eigener Infrastruktur bereit und
|
||||
betreibt sie. Welcher Speicherplatz, welches Datenvolumen und wie viele Benutzerkonten enthalten
|
||||
sind, ergibt sich aus dem gebuchten Paket, das dem Kunden bei der Bestellung und im Kundenportal
|
||||
angezeigt wird. Zum Betrieb gehören das Einspielen von Sicherheits- und Versionsaktualisierungen,
|
||||
die Überwachung des Dienstes und die im Paket beschriebenen Sicherungen.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Der Standort der Instanz wird bei der Bestellung angezeigt. Ein Anspruch auf einen bestimmten
|
||||
Server besteht nicht; CluPilot darf eine Instanz auf einen anderen Server derselben Region
|
||||
verlegen, wenn der Betrieb dies erfordert.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">3. Zustandekommen des Vertrags</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Die Darstellung der Pakete auf der Website ist kein Angebot, sondern eine Einladung zur Bestellung.
|
||||
Bestellt wird ausschließlich im Kundenportal. Der Kunde gibt seine Bestellung ab, indem er diese
|
||||
AGB durch Setzen des Häkchens annimmt und anschließend die Bezahlung abschließt. Ohne diese Annahme
|
||||
kann keine Bestellung abgeschickt werden.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Der Vertrag kommt mit dem Eingang der Zahlung zustande. Der Kunde erhält darüber eine Bestätigung
|
||||
per E-Mail; die Rechnung wird gesondert zugestellt und ist im Kundenportal abrufbar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">4. Preise, Umsatzsteuer und Zahlung</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Die im Kundenportal angezeigten Preise für Verbraucher sind Bruttopreise und enthalten
|
||||
{{ $vat }} % Umsatzsteuer. Unternehmerischen Kunden werden die Preise zusätzlich netto ausgewiesen;
|
||||
bei einer gültigen UID aus einem anderen EU-Mitgliedstaat wird die Umsatzsteuer nach dem
|
||||
Reverse-Charge-Verfahren nicht in Rechnung gestellt.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Das Entgelt ist monatlich im Voraus fällig und wird über den Zahlungsdienstleister Stripe
|
||||
eingezogen.
|
||||
@if ($setup > 0)
|
||||
Zusätzlich wird eine einmalige Einrichtungsgebühr von
|
||||
{{ Number::currency($setup / 100, in: 'EUR', locale: 'de') }} verrechnet; sie ist bei der
|
||||
ersten Zahlung fällig und wird bei der Bestellung ausgewiesen.
|
||||
@else
|
||||
Eine Einrichtungsgebühr wird derzeit nicht verrechnet. Fällt eine an, wird sie bei der
|
||||
Bestellung ausgewiesen und ist mit der ersten Zahlung fällig.
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Zusatzleistungen (etwa zusätzlicher Speicher oder eine eigene Domain) werden zum jeweils
|
||||
angezeigten Preis gebucht und ab der nächsten Abrechnungsperiode gemeinsam mit dem Paket
|
||||
verrechnet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">5. Bereitstellung</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Die Einrichtung beginnt unmittelbar nach Eingang der Zahlung und ist üblicherweise innerhalb
|
||||
weniger Minuten abgeschlossen. Der Kunde erhält seine Zugangsdaten per E-Mail, sobald die Instanz
|
||||
bereitsteht. Eine bestimmte Bereitstellungsdauer wird nicht zugesagt.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Steht zum Zeitpunkt der Bestellung keine freie Kapazität zur Verfügung, wird die Bestellung
|
||||
vorgemerkt und ausgeliefert, sobald Kapazität geschaffen ist; der Kunde wird darüber informiert.
|
||||
Ist die Bereitstellung binnen 14 Tagen nicht möglich, kann der Kunde vom Vertrag zurücktreten und
|
||||
erhält den gezahlten Betrag zurück.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">6. Widerrufsrecht für Verbraucher</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Verbraucher können binnen 14 Tagen ab Vertragsabschluss ohne Angabe von Gründen vom Vertrag
|
||||
zurücktreten. Für die Erklärung genügt eine Mitteilung an
|
||||
<a href="mailto:{{ $company['email'] }}" class="font-medium text-accent-text underline-offset-4 hover:underline">{{ $company['email'] }}</a>;
|
||||
im Kundenportal steht dafür auch eine Schaltfläche bereit.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Mit der Annahme dieser AGB bei der Bestellung verlangt der Kunde ausdrücklich, dass CluPilot mit
|
||||
der Leistung schon vor Ablauf dieser Frist beginnt — anders wäre eine sofortige Bereitstellung
|
||||
nicht möglich. Das Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist
|
||||
erhält der Kunde den <strong class="font-semibold text-ink">gesamten gezahlten Betrag</strong>
|
||||
zurück. Ein Wertersatz für die Tage, an denen die Cloud bereits gelaufen ist, wird nicht verlangt,
|
||||
obwohl § 16 FAGG dies zulassen würde. Über den Widerruf wird eine Storno-Rechnung ausgestellt.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Unternehmerischen Kunden steht dieses Rücktrittsrecht nicht zu.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">7. Laufzeit und Kündigung</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Der Vertrag läuft auf unbestimmte Zeit und kann vom Kunden jederzeit im Kundenportal zum Ende der
|
||||
laufenden Abrechnungsperiode gekündigt werden. Bis dahin bleibt die Cloud vollständig nutzbar. Zum
|
||||
Ende der Periode erhält der Kunde einen Datenexport; danach wird die Instanz abgebaut und die
|
||||
Daten werden gelöscht.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
CluPilot kann den Vertrag mit einer Frist von drei Monaten zum Monatsende kündigen sowie aus
|
||||
wichtigem Grund mit sofortiger Wirkung, insbesondere bei Zahlungsverzug trotz Mahnung oder bei
|
||||
einem Verstoß gegen Punkt 9.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">8. Kundenkonto und Löschfristen</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Ein Konto entsteht mit der Registrierung im Kundenportal und ist erst nach Bestätigung der
|
||||
E-Mail-Adresse nutzbar. CluPilot löscht Konten von selbst, damit keine unbenutzten Daten liegen
|
||||
bleiben:
|
||||
</p>
|
||||
<ul class="ml-5 list-disc space-y-2 text-md leading-relaxed text-body">
|
||||
<li>
|
||||
<strong class="font-semibold text-ink">Nicht bestätigte Registrierungen</strong> werden nach
|
||||
{{ $unverifiedDays }} Tagen gelöscht. Die E-Mail-Adresse ist danach wieder frei.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="font-semibold text-ink">Bestätigte Konten ohne Paket</strong> werden nach einem
|
||||
Jahr gelöscht, wenn zu ihnen nie ein Paket, eine Buchung oder eine Rechnung bestanden hat.
|
||||
CluPilot kündigt das vorher per E-Mail an; eine Anmeldung oder eine Bestellung setzt die Frist
|
||||
zurück.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Ein Konto mit gebuchtem Paket wird nicht automatisch gelöscht. Der Kunde kann sein Konto jederzeit
|
||||
selbst schließen, sobald kein Paket mehr läuft. Rechnungen und Buchhaltungsunterlagen bewahrt
|
||||
CluPilot unabhängig davon sieben Jahre auf — dazu ist sie nach § 132 BAO verpflichtet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">9. Pflichten des Kunden</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Der Kunde bewahrt seine Zugangsdaten sorgfältig auf und ist für die Inhalte verantwortlich, die er
|
||||
und die von ihm angelegten Benutzer in der Cloud speichern. Rechtswidrige Inhalte, das Versenden
|
||||
unerbetener Massen-E-Mails und jede Nutzung, die den Betrieb der Plattform gefährdet, sind
|
||||
untersagt. CluPilot sieht Kundendaten nicht ein, außer der Kunde bittet im Rahmen einer
|
||||
Supportanfrage darum oder eine gesetzliche Verpflichtung besteht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">10. Verfügbarkeit und Wartung</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
CluPilot betreibt die Plattform mit der Sorgfalt eines fachkundigen Betreibers, schuldet aber keine
|
||||
bestimmte Verfügbarkeit, solange keine gesonderte Vereinbarung darüber getroffen wurde. Geplante
|
||||
Wartungen werden im Kundenportal und per E-Mail angekündigt und nach Möglichkeit in
|
||||
verkehrsschwache Zeiten gelegt. Sicherheitsaktualisierungen können auch ohne Vorankündigung
|
||||
eingespielt werden, wenn ein Aufschub ein Risiko wäre.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">11. Datenschutz</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Welche Daten CluPilot zu welchem Zweck verarbeitet, steht in der
|
||||
<a href="{{ route('legal.datenschutz') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">Datenschutzerklärung</a>.
|
||||
Soweit CluPilot Daten im Auftrag des Kunden verarbeitet, gilt dafür ein Vertrag über die
|
||||
Auftragsverarbeitung nach Art. 28 DSGVO, den der Kunde anfordern kann.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">12. Haftung</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
CluPilot haftet nach den gesetzlichen Bestimmungen. Gegenüber unternehmerischen Kunden ist die
|
||||
Haftung für leichte Fahrlässigkeit ausgeschlossen; die Haftung für Personenschäden sowie für Vorsatz
|
||||
und grobe Fahrlässigkeit bleibt in jedem Fall unberührt. Gegenüber Verbrauchern wird die Haftung
|
||||
nicht eingeschränkt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">13. Änderungen dieser AGB</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
CluPilot kann diese AGB ändern und teilt Änderungen mindestens sechs Wochen vor ihrem Wirksamwerden
|
||||
per E-Mail mit. Widerspricht der Kunde nicht bis zum Wirksamwerden, gelten die Änderungen als
|
||||
angenommen; darauf wird in der Mitteilung ausdrücklich hingewiesen. Widerspricht er, kann jede
|
||||
Seite den Vertrag zum Zeitpunkt des Wirksamwerdens kündigen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-md font-bold tracking-[-0.01em] text-ink">14. Schlussbestimmungen</h2>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Es gilt österreichisches Recht. Gegenüber Verbrauchern bleiben zwingende Schutzvorschriften ihres
|
||||
Aufenthaltsstaats unberührt, und es gilt der gesetzliche Gerichtsstand. Gegenüber unternehmerischen
|
||||
Kunden ist das für {{ $company['city'] }} sachlich zuständige Gericht zuständig. Sollte eine
|
||||
Bestimmung unwirksam sein, bleibt der Vertrag im Übrigen gültig.
|
||||
</p>
|
||||
<p class="text-md leading-relaxed text-body">
|
||||
Fragen dazu jederzeit an
|
||||
<a href="mailto:{{ $company['email'] }}" class="font-medium text-accent-text underline-offset-4 hover:underline">{{ $company['email'] }}</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<x-ui.button href="{{ route('home') }}" variant="secondary">
|
||||
<x-ui.icon name="arrow-left" class="size-4" />
|
||||
Zurück zur Startseite
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
|
@ -18,7 +18,17 @@
|
|||
<li wire:key="prev-{{ $key }}" class="flex flex-wrap items-center gap-3 px-6 py-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-ink">{{ $label }}</p>
|
||||
<p class="font-mono text-xs text-muted">{{ $key }}</p>
|
||||
{{-- Which mailbox it goes out from, because that is the
|
||||
field a mail server checks: an account may only send
|
||||
from the address it owns, and a mismatch here is the
|
||||
"553 Sender address rejected" that has no other
|
||||
visible cause. --}}
|
||||
<p class="font-mono text-xs text-muted">
|
||||
{{ $key }}
|
||||
@if ($senders[$key] ?? null)
|
||||
· {{ $senders[$key] }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- A new tab, not an iframe: a mail is a whole document with
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
{{-- Said here, not only in the mail: somebody who never got the mail is
|
||||
looking at THIS page, and the deadline is the reason they do not
|
||||
have to do anything about an attempt they abandon. --}}
|
||||
{{-- Both deadlines, because reading only the first one ("deleted after
|
||||
five days") leaves somebody thinking a confirmed account goes the
|
||||
same way. It does not: the five days are about a registration nobody
|
||||
confirmed. --}}
|
||||
<p class="mt-5 border-t border-line pt-4 text-xs text-muted">{{ __('verify_email.notice_wrong_address') }}</p>
|
||||
<p class="mt-2 text-xs text-muted">{{ __('verify_email.notice_after_confirm') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,35 +32,42 @@
|
|||
@else
|
||||
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
|
||||
|
||||
<div x-data="{ immediateStart: false }" class="space-y-6">
|
||||
{{-- ── The consent, first ────────────────────────────────────────
|
||||
It was at the bottom, and every buy button was disabled until it
|
||||
was ticked — so the page arrived looking broken. Read in the
|
||||
order it applies: this is the condition, the packages are the
|
||||
choice.
|
||||
<div x-data="{ terms: false }" class="space-y-6">
|
||||
{{-- ── The terms, first ──────────────────────────────────────────
|
||||
One declaration, and the terms behind it regulate the sale:
|
||||
price, VAT, delivery, cancellation, the fourteen-day withdrawal
|
||||
right and the express request to begin at once. The box used to
|
||||
carry that last one as a paragraph of its own, which read like a
|
||||
choice between "now" and "in fourteen days" — and there is no
|
||||
second option.
|
||||
|
||||
The server rule is still the real gate (CheckoutController
|
||||
validates `accepted`), so with JavaScript off the field arrives
|
||||
empty and the checkout is refused with the sentence explaining
|
||||
why. Ticking a box in a browser was never the record: the record
|
||||
is the timestamp taken when the payment lands. --}}
|
||||
The label is not the whole contract in small print: it says what
|
||||
is being accepted, links to it, and summarises underneath what a
|
||||
reader most needs to know before ticking.
|
||||
|
||||
The server rule is the real gate (CheckoutController validates
|
||||
`accepted`), so with JavaScript off the field arrives empty and
|
||||
the checkout is refused with the sentence explaining why. --}}
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-5 shadow-xs transition-colors animate-rise [animation-delay:40ms]"
|
||||
x-bind:class="immediateStart ? 'border-success-border bg-success-bg' : 'border-accent-border bg-accent-subtle'">
|
||||
<input type="checkbox" x-model="immediateStart"
|
||||
x-bind:class="terms ? 'border-success-border bg-success-bg' : 'border-accent-border bg-accent-subtle'">
|
||||
<input type="checkbox" x-model="terms"
|
||||
class="mt-0.5 size-4 shrink-0 rounded border-line-strong text-accent-active focus:ring-accent-active" />
|
||||
<span class="min-w-0">
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('order.consent_title') }}</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
|
||||
{{-- What happens if it is left unticked, said here rather than
|
||||
only in the refusal afterwards. The box reads like a
|
||||
choice between "now" and "in fourteen days", and there is
|
||||
no second option: nothing here delivers later, so an
|
||||
unticked box means no order at all. --}}
|
||||
<span class="mt-2 block text-xs leading-relaxed text-muted">{{ __('order.consent_why') }}</span>
|
||||
<span class="block text-sm font-semibold text-ink">{{ __('order.terms_title') }}</span>
|
||||
<span class="mt-1 block text-sm leading-relaxed text-body">
|
||||
{{-- A new tab: following the link must not lose the page
|
||||
with the choice on it. --}}
|
||||
{!! __('order.terms_label', [
|
||||
'link' => '<a href="'.route('legal.agb').'" target="_blank" rel="noopener"
|
||||
class="font-semibold text-accent-text underline underline-offset-4">'
|
||||
.e(__('order.terms_link')).'</a>',
|
||||
]) !!}
|
||||
</span>
|
||||
<span class="mt-2 block text-xs leading-relaxed text-muted">{{ __('order.terms_why') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@error('immediate_start')
|
||||
@error('terms_accepted')
|
||||
<x-ui.alert variant="error">{{ $message }}</x-ui.alert>
|
||||
@enderror
|
||||
|
||||
|
|
@ -128,17 +135,26 @@
|
|||
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
|
||||
@csrf
|
||||
<input type="hidden" name="plan" value="{{ $plan['key'] }}" />
|
||||
<input type="hidden" name="immediate_start" x-bind:value="immediateStart ? '1' : ''" />
|
||||
<input type="hidden" name="terms_accepted" x-bind:value="terms ? '1' : ''" />
|
||||
|
||||
{{-- Enabled, with the reason it will not go through
|
||||
said next to it. A disabled button explains
|
||||
nothing; this one is refused by the server with a
|
||||
sentence, and the line below says so first. --}}
|
||||
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full">
|
||||
{{-- No order without the terms — so the button is not
|
||||
usable until they are accepted, and says why
|
||||
underneath rather than leaving somebody to guess
|
||||
at a dead button. The refusal on the server
|
||||
stands regardless: this is the visible half of
|
||||
one rule, not the rule itself.
|
||||
|
||||
x-bind on a plain attribute, not @disabled on the
|
||||
component — R24#3: a Blade directive in a
|
||||
component tag's attribute list lands in the
|
||||
attribute bag and takes the view apart. --}}
|
||||
<x-ui.button type="submit" :variant="$plan['recommended'] ? 'primary' : 'secondary'" class="w-full"
|
||||
x-bind:disabled="!terms"
|
||||
x-bind:class="terms ? '' : 'cursor-not-allowed opacity-50'">
|
||||
{{ __('order.buy') }}
|
||||
</x-ui.button>
|
||||
<p class="mt-2 text-center text-[11px] leading-snug text-muted" x-show="!immediateStart" x-cloak>
|
||||
{{ __('order.consent_first') }}
|
||||
<p class="mt-2 text-center text-[11px] leading-snug text-muted" x-show="!terms" x-cloak>
|
||||
{{ __('order.terms_first') }}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -275,6 +275,25 @@
|
|||
{{ __('settings.close_cta') }}
|
||||
</x-ui.button>
|
||||
</div>
|
||||
|
||||
{{-- The deadlines, said where somebody asks the question.
|
||||
"Nach fünf Tagen gelöscht" on the verification page reads as if
|
||||
it applied to every account; it applies to a registration nobody
|
||||
confirmed. The other rule — a year without a package — was
|
||||
nowhere at all. Both come from the commands that enforce them, so
|
||||
the page cannot drift from what actually happens. --}}
|
||||
<div class="mt-4 rounded-md border border-line bg-surface-2 p-4">
|
||||
<p class="text-sm font-semibold text-ink">{{ __('settings.lifecycle_title') }}</p>
|
||||
<ul class="mt-2 space-y-1.5 text-sm leading-relaxed text-muted">
|
||||
<li>{{ __('settings.lifecycle_unverified', ['days' => App\Console\Commands\PruneUnverifiedAccounts::AFTER_DAYS]) }}</li>
|
||||
<li>{{ __('settings.lifecycle_dormant', ['warn' => App\Console\Commands\PruneDormantAccounts::WARN_DAYS_BEFORE]) }}</li>
|
||||
<li>{{ __('settings.lifecycle_kept') }}</li>
|
||||
</ul>
|
||||
<a href="{{ route('legal.agb') }}" target="_blank" rel="noopener"
|
||||
class="mt-3 inline-block text-sm font-medium text-accent-text underline-offset-4 hover:underline">
|
||||
{{ __('settings.lifecycle_terms') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<x-mail.layout
|
||||
:heading="__('dormant_mail.heading')"
|
||||
:preheader="__('dormant_mail.preheader', ['days' => $days])"
|
||||
:greeting="$name !== '' ? __('dormant_mail.greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 24px 20px 24px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('dormant_mail.intro', ['days' => $days]) }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- The one thing that stops it. A single button, because there is exactly one
|
||||
action and asking somebody to "log in or place an order or write to us"
|
||||
leaves them deciding which. --}}
|
||||
<tr><td style="padding:0 24px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('dormant_mail.how_to_keep') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $loginUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('dormant_mail.action') }}</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 24px 32px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
{{-- Said out loud: nothing is lost, because there was never anything
|
||||
to lose. Somebody who reads "your account will be deleted" and
|
||||
owns nothing still wonders about invoices and files. --}}
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('dormant_mail.nothing_lost') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -65,6 +65,14 @@ Schedule::command('clupilot:prune-unverified')
|
|||
->dailyAt('03:20')
|
||||
->withoutOverlapping();
|
||||
|
||||
// Confirmed accounts that never had a package. A year, with a fortnight's notice
|
||||
// by mail — so this has to run daily even though the window is long: the notice
|
||||
// is what permits the deletion, and an account whose warning was never sent is
|
||||
// never removed. See App\Console\Commands\PruneDormantAccounts.
|
||||
Schedule::command('clupilot:prune-dormant')
|
||||
->dailyAt('03:35')
|
||||
->withoutOverlapping();
|
||||
|
||||
// The support mailbox. Polling, because IMAP is a mailbox you look in — a mail
|
||||
// server has no way to tell us something arrived — so the interval IS the
|
||||
// answer to "how long until I see it". Two minutes is short enough that an
|
||||
|
|
|
|||
|
|
@ -208,7 +208,11 @@ $publicSite = function () {
|
|||
// but the address is not part of the copy.
|
||||
Route::get('/imprint', fn () => view('legal', ['title' => 'Impressum']))->name('impressum');
|
||||
Route::get('/privacy', fn () => view('legal', ['title' => 'Datenschutz']))->name('datenschutz');
|
||||
Route::get('/terms', fn () => view('legal', ['title' => 'AGB']))->name('agb');
|
||||
// The AGB has a text now (resources/views/legal/terms.blade.php): the
|
||||
// order page requires accepting it, and it is where the withdrawal
|
||||
// right, the immediate start and the account deletion deadlines are
|
||||
// regulated rather than repeated in a checkbox nobody can read.
|
||||
Route::get('/terms', fn () => view('legal', ['title' => 'AGB', 'page' => 'terms']))->name('agb');
|
||||
|
||||
// The German paths these shipped under. Permanent, because they have
|
||||
// been in mail footers and in the site footer for weeks.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Livewire\Admin\MailPreview;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Mailbox;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Order;
|
||||
use App\Services\Mail\MailPreviews;
|
||||
|
|
@ -72,6 +73,54 @@ it('sends a preview to the operator own address and to nobody else', function ()
|
|||
Mail::assertNotQueued(App\Mail\VerifyEmailMail::class);
|
||||
});
|
||||
|
||||
it('sends each preview through the mailer the mail itself asks for', function () {
|
||||
// "553 5.7.1 Sender address rejected: not owned by user no-reply@…".
|
||||
//
|
||||
// Every purpose mailbox has its OWN SMTP account, and a mail server lets an
|
||||
// account send only from the address it owns. Mail::to() hands back a pending
|
||||
// mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the
|
||||
// mailable asked for — so a support mail addressed support@ went out over the
|
||||
// no-reply@ login and was rejected. The mailer is not a detail here; it IS
|
||||
// which account authenticates.
|
||||
Mail::fake();
|
||||
|
||||
Livewire::actingAs(operator('Owner'), 'operator')
|
||||
->test(MailPreview::class)
|
||||
->call('sendToMe', 'operator-message');
|
||||
|
||||
Mail::assertSent(App\Mail\OperatorMessageMail::class, fn ($mail) => $mail->mailer === 'cp_support');
|
||||
});
|
||||
|
||||
it('gives every preview a sender its own mailbox owns', function () {
|
||||
// The invariant behind the 553: From has to be the address of the mailbox
|
||||
// whose account signs in. A preview that takes the framework default
|
||||
// (MAIL_FROM_ADDRESS, which no mailbox owns) is rejected by any properly
|
||||
// configured server — which is exactly how the cloud-ready preview failed
|
||||
// while all nine rendered perfectly in the browser.
|
||||
// updateOrCreate, because the installation already seeds mailboxes: this
|
||||
// test cares about the mapping and the addresses, not about who created the
|
||||
// rows.
|
||||
foreach (App\Services\Mail\MailPurpose::ALL as $purpose) {
|
||||
Mailbox::query()->updateOrCreate(['key' => $purpose], [
|
||||
'address' => $purpose.'@clupilot.test',
|
||||
'password' => 'test-passwort',
|
||||
'authenticates' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
App\Support\Settings::set(App\Services\Mail\MailPurpose::settingKey($purpose), $purpose);
|
||||
}
|
||||
|
||||
foreach (array_keys(app(MailPreviews::class)->all()) as $key) {
|
||||
$mailable = app(MailPreviews::class)->make($key);
|
||||
|
||||
// Which mailer it will go out over, and therefore which account signs in.
|
||||
expect($mailable->mailer)->toBeString()->and($mailable->mailer)->toStartWith('cp_');
|
||||
|
||||
$purpose = str_replace('cp_', '', $mailable->mailer);
|
||||
$mailable->assertFrom($purpose.'@clupilot.test');
|
||||
}
|
||||
});
|
||||
|
||||
it('has no recipient field, because that would be a form for mailing strangers', function () {
|
||||
$component = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/MailPreview.php'));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\PruneDormantAccounts;
|
||||
use App\Console\Commands\PruneUnverifiedAccounts;
|
||||
use App\Mail\DormantAccountWarningMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* A confirmed account that never had a package goes after a year.
|
||||
*
|
||||
* The five-day sweep is about registrations nobody confirmed — see
|
||||
* UnverifiedAccountTest. This is the other half of the same question the owner
|
||||
* asked: "wird nun der account gelöscht nach 5 tagen?" No. Only an unconfirmed
|
||||
* one. A confirmed account stays, unless a year passes with no package on it at
|
||||
* all — and then it is told a fortnight in advance.
|
||||
*/
|
||||
function dormantUser(int $daysOld, array $attributes = []): User
|
||||
{
|
||||
$user = User::factory()->create(array_merge(['email_verified_at' => now()->subDays($daysOld)], $attributes));
|
||||
|
||||
$user->forceFill(['created_at' => now()->subDays($daysOld)])->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('warns a fortnight before, rather than deleting out of the blue', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS - PruneDormantAccounts::WARN_DAYS_BEFORE);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant')->assertSuccessful();
|
||||
|
||||
Mail::assertQueued(DormantAccountWarningMail::class, fn ($mail) => $mail->hasTo($user->email));
|
||||
|
||||
// Still here. The warning is a warning, not the deletion.
|
||||
expect($user->fresh())->not->toBeNull()
|
||||
->and($user->fresh()->dormant_warned_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('warns once, not every night for a fortnight', function () {
|
||||
Mail::fake();
|
||||
|
||||
dormantUser(PruneDormantAccounts::AFTER_DAYS - 5);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
Mail::assertQueuedCount(1);
|
||||
});
|
||||
|
||||
it('deletes the account a year on, once the warning has had its fortnight', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 1);
|
||||
$user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE + 1)])->save();
|
||||
|
||||
$this->artisan('clupilot:prune-dormant')->assertSuccessful();
|
||||
|
||||
expect(User::query()->whereKey($user->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('never deletes an account that was never warned', function () {
|
||||
// The stamp is what permits the deletion. An installation whose mail server
|
||||
// was down for a month delays the deletion; it does not skip the notice.
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
expect(User::query()->whereKey($user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('gives the warning its full fortnight before deleting', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS + 30);
|
||||
$user->forceFill(['dormant_warned_at' => now()->subDays(PruneDormantAccounts::WARN_DAYS_BEFORE - 1)])->save();
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
expect(User::query()->whereKey($user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('leaves an account alone that signed in inside the year', function () {
|
||||
// What the terms promise: signing in once starts the period again. There is
|
||||
// no last_login_at on users, and the device rows carry exactly this.
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 3);
|
||||
UserDevice::query()->create([
|
||||
'guard' => 'web',
|
||||
'authenticatable_id' => $user->id,
|
||||
'token_hash' => hash('sha256', 'egal'),
|
||||
'name' => 'Firefox',
|
||||
'last_seen_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
expect(User::query()->whereKey($user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('counts a device of another guard as nobody, because operator 1 is not customer 1', function () {
|
||||
// R21. A device row of the operator guard with the same id says nothing
|
||||
// about this customer, and treating it as activity would keep an abandoned
|
||||
// account alive for as long as an operator keeps signing in.
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1);
|
||||
UserDevice::query()->create([
|
||||
'guard' => 'operator',
|
||||
'authenticatable_id' => $user->id,
|
||||
'token_hash' => hash('sha256', 'anders'),
|
||||
'name' => 'Konsole',
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
Mail::assertQueued(DormantAccountWarningMail::class);
|
||||
});
|
||||
|
||||
it('never touches an unconfirmed account, which is the other sweep business', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email_verified_at' => null]);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
expect(User::query()->whereKey($user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('never touches an account with a customer record, by id or by address', function () {
|
||||
// A customer record means somebody in the business knows this person, and
|
||||
// everything commercial — orders, contracts, invoices kept for seven years —
|
||||
// hangs off one. Both links count: see Customer::emailTaken.
|
||||
Mail::fake();
|
||||
|
||||
$byId = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2);
|
||||
Customer::factory()->create(['user_id' => $byId->id, 'email' => 'andere@example.test']);
|
||||
|
||||
$byEmail = dormantUser(PruneDormantAccounts::AFTER_DAYS * 2, ['email' => 'gleich@example.test']);
|
||||
Customer::factory()->create(['email' => 'gleich@example.test', 'user_id' => null]);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant');
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
expect(User::query()->whereKey($byId->id)->exists())->toBeTrue()
|
||||
->and(User::query()->whereKey($byEmail->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('says what it would do without doing it', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = dormantUser(PruneDormantAccounts::AFTER_DAYS - 1, ['email' => 'ruht@example.test']);
|
||||
|
||||
$this->artisan('clupilot:prune-dormant --dry-run')
|
||||
->expectsOutputToContain('ruht@example.test')
|
||||
->assertSuccessful();
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
expect($user->fresh()->dormant_warned_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('runs on the schedule, or it never runs at all', function () {
|
||||
// Daily even though the window is a year: the notice is what permits the
|
||||
// deletion, so the run that sends it has to happen every day.
|
||||
expect(File::get(base_path('routes/console.php')))
|
||||
->toContain("Schedule::command('clupilot:prune-dormant')")
|
||||
->toContain('withoutOverlapping');
|
||||
});
|
||||
|
||||
// ---- What a customer is told ----
|
||||
|
||||
it('states both deadlines in the portal, not just the five days', function () {
|
||||
// The question this answers: "wird nun der account gelöscht nach 5 tagen?"
|
||||
// Reading only the verification page, the answer looked like yes.
|
||||
$settings = File::get(resource_path('views/livewire/settings.blade.php'));
|
||||
|
||||
expect($settings)->toContain("__('settings.lifecycle_unverified'")
|
||||
->and($settings)->toContain("__('settings.lifecycle_dormant'")
|
||||
// From the commands themselves, so the page cannot promise one number
|
||||
// while the sweep uses another.
|
||||
->and($settings)->toContain('PruneUnverifiedAccounts::AFTER_DAYS')
|
||||
->and($settings)->toContain('PruneDormantAccounts::WARN_DAYS_BEFORE');
|
||||
});
|
||||
|
||||
it('regulates both deadlines in the terms, because that is where it was promised', function () {
|
||||
$terms = $this->get(route('legal.agb'))->assertOk()->getContent();
|
||||
|
||||
expect($terms)->toContain('Löschfristen')
|
||||
->and($terms)->toContain('Nicht bestätigte Registrierungen')
|
||||
->and($terms)->toContain('Bestätigte Konten ohne Paket')
|
||||
// And the number in the text is the number the command uses.
|
||||
->and($terms)->toContain(PruneUnverifiedAccounts::AFTER_DAYS.' Tagen');
|
||||
});
|
||||
|
|
@ -117,9 +117,9 @@ it('charges a domestic consumer and a domestic business the same gross, to the c
|
|||
'vat_id_verified_value' => 'ATU12345678',
|
||||
]);
|
||||
|
||||
$this->actingAs($consumer)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1'])
|
||||
$this->actingAs($consumer)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
|
||||
->assertRedirect();
|
||||
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1'])
|
||||
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
|
||||
->assertRedirect();
|
||||
|
||||
// 179,00 € plus 20 %, which is the figure on the website.
|
||||
|
|
@ -135,7 +135,7 @@ it('charges a verified business in another member state the bare net, and invoic
|
|||
Queue::fake();
|
||||
[$user] = verifiedEuBusiness();
|
||||
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1'])
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
|
||||
->assertRedirect();
|
||||
|
||||
// The bare net: no VAT is owed to us, so there is none in the price either.
|
||||
|
|
@ -188,7 +188,7 @@ it('charges an unverified EU business the gross, because an unchecked number is
|
|||
'vat_id' => 'NL123456789B01',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1'])
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
|
||||
->assertRedirect();
|
||||
|
||||
expect(chargedByCheckout($this->stripe))->toBe(21480)
|
||||
|
|
@ -202,8 +202,8 @@ it('charges the setup fee under the same rule as the package', function () {
|
|||
[$domestic] = buyerWith(['customer_type' => Customer::TYPE_CONSUMER]);
|
||||
[$business] = verifiedEuBusiness();
|
||||
|
||||
$this->actingAs($domestic)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']);
|
||||
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1']);
|
||||
$this->actingAs($domestic)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
|
||||
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
|
||||
|
||||
// A fee is a supply like the package it sets up, so it cannot be taxed
|
||||
// differently from it on the same invoice — and the webhook is told how much
|
||||
|
|
@ -393,7 +393,7 @@ it('refuses the sale rather than overcharging a business the catalogue has no ne
|
|||
|
||||
[$user] = verifiedEuBusiness();
|
||||
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'immediate_start' => '1'])
|
||||
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
|
||||
->assertSessionHasErrors('plan');
|
||||
|
||||
expect($this->stripe->checkouts)->toBeEmpty();
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function setupFeeBuyer(): User
|
|||
|
||||
it('charges the fee it advertises, as a one-off line beside the monthly one', function () {
|
||||
$this->actingAs(setupFeeBuyer())
|
||||
->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1'])
|
||||
->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => '1'])
|
||||
->assertRedirect();
|
||||
|
||||
$checkout = $this->stripe->checkouts[0];
|
||||
|
|
@ -72,7 +72,7 @@ it('sends no line at all when the operator charges no setup fee', function () {
|
|||
// something.
|
||||
Settings::set('company.setup_fee_cents', 0);
|
||||
|
||||
$this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => '1']);
|
||||
$this->actingAs(setupFeeBuyer())->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => '1']);
|
||||
|
||||
expect($this->stripe->checkouts[0]['one_off'])->toBeNull()
|
||||
// Stated as zero rather than left out. The webhook reads it either way,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* A From address belongs to the account that signs in.
|
||||
*
|
||||
* Every purpose mailbox in this installation has its OWN SMTP account, and a
|
||||
* mail server lets an account send only from the address it owns. mailcow
|
||||
* answers anything else with "553 5.7.1 Sender address rejected: not owned by
|
||||
* user no-reply@…" — and it is right to.
|
||||
*
|
||||
* So a mail that takes its From from a mailbox has to go out over that mailbox's
|
||||
* mailer as well. Setting one and forgetting the other produces a mail that
|
||||
* renders perfectly, queues without complaint, and never arrives: InvoiceMail
|
||||
* and OrderConfirmationMail both addressed billing@ while going out over the
|
||||
* default mailer's no-reply@ login, which means no customer had ever received an
|
||||
* invoice mail or an order confirmation on a server that checks.
|
||||
*/
|
||||
it('gives every mail that takes a mailbox From the matching mailer', function () {
|
||||
$offenders = [];
|
||||
|
||||
foreach (File::allFiles(app_path('Mail')) as $file) {
|
||||
$code = $file->getContents();
|
||||
|
||||
// Only the ones that take their sender from a mailbox. A mailable with
|
||||
// no mailbox From has nothing to mismatch.
|
||||
if (! str_contains($code, 'mailboxEnvelope(') && ! str_contains($code, 'mailboxAddresses(')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
preg_match('/mailbox(?:Envelope|Addresses)\(\s*MailPurpose::([A-Z_]+)/', $code, $from);
|
||||
preg_match("/->mailer\('cp_'\.MailPurpose::([A-Z_]+)\)/", $code, $mailer);
|
||||
|
||||
$fromPurpose = $from[1] ?? null;
|
||||
$mailerPurpose = $mailer[1] ?? null;
|
||||
|
||||
if ($fromPurpose !== null && $fromPurpose !== $mailerPurpose) {
|
||||
$offenders[] = $file->getFilename().': From '.$fromPurpose.', mailer '.($mailerPurpose ?? 'DEFAULT');
|
||||
}
|
||||
}
|
||||
|
||||
expect($offenders)->toBe([]);
|
||||
});
|
||||
|
||||
it('sends each purpose over its own mailer, not over whatever mail.default is', function () {
|
||||
// The purpose mailers exist so that each mailbox authenticates as itself.
|
||||
// A mailable naming no mailer silently uses mail.default, which on this
|
||||
// installation is the plain smtp mailer with ONE account behind it.
|
||||
foreach (App\Services\Mail\MailPurpose::ALL as $purpose) {
|
||||
expect(config('mail.mailers.cp_'.$purpose))
|
||||
->toBe(['transport' => 'mailbox', 'purpose' => $purpose]);
|
||||
}
|
||||
});
|
||||
|
|
@ -36,7 +36,7 @@ beforeEach(function () {
|
|||
*/
|
||||
function buying(string $plan): array
|
||||
{
|
||||
return ['plan' => $plan, 'immediate_start' => '1'];
|
||||
return ['plan' => $plan, 'terms_accepted' => '1'];
|
||||
}
|
||||
|
||||
/** A signed-in, verified portal user. */
|
||||
|
|
@ -191,14 +191,14 @@ it('does not promise a migration it has not looked at', function () {
|
|||
->not->toContain('wie Ihre Daten sicher umziehen');
|
||||
});
|
||||
|
||||
it('refuses to open a checkout without the express request to begin at once', function () {
|
||||
it('refuses to open a checkout without the terms being accepted', function () {
|
||||
// The gap this closes: the webhook has always read `immediate_start` off the
|
||||
// session metadata, and the checkout never put it there. So every contract
|
||||
// was concluded WITHOUT recorded consent, and every withdrawal — even on day
|
||||
// thirteen of a running cloud — had to refund the full amount.
|
||||
$this->actingAs(buyer())
|
||||
->post(route('checkout.start'), ['plan' => 'start'])
|
||||
->assertSessionHasErrors('immediate_start');
|
||||
->assertSessionHasErrors('terms_accepted');
|
||||
|
||||
expect($this->stripe->checkouts)->toBeEmpty();
|
||||
});
|
||||
|
|
@ -209,8 +209,8 @@ it('does not take a box that was merely present as a yes', function () {
|
|||
// not a consent.
|
||||
foreach (['', '0', 'nein'] as $value) {
|
||||
$this->actingAs(buyer())
|
||||
->post(route('checkout.start'), ['plan' => 'start', 'immediate_start' => $value])
|
||||
->assertSessionHasErrors('immediate_start');
|
||||
->post(route('checkout.start'), ['plan' => 'start', 'terms_accepted' => $value])
|
||||
->assertSessionHasErrors('terms_accepted');
|
||||
}
|
||||
|
||||
expect($this->stripe->checkouts)->toBeEmpty();
|
||||
|
|
@ -225,15 +225,28 @@ it('carries the consent to Stripe as exactly the string the webhook compares aga
|
|||
expect($this->stripe->checkouts[0]['metadata']['immediate_start'])->toBe('1');
|
||||
});
|
||||
|
||||
it('puts the consent in front of the customer, in the law own words', function () {
|
||||
// On the page, once, next to the buttons — not buried in terms nobody
|
||||
// opens. The sentence is the statutory one, and the buy buttons stay
|
||||
// disabled until it is ticked.
|
||||
it('names what is being accepted, and links to it', function () {
|
||||
// Not a box saying "I agree" with nothing behind it: the label says what,
|
||||
// links to the document, and summarises beside it what a reader most needs to
|
||||
// know before ticking — that delivery starts at once and the withdrawal right
|
||||
// survives it.
|
||||
$this->actingAs(buyer())
|
||||
->get(route('order'))
|
||||
->assertOk()
|
||||
->assertSee(__('checkout.immediate_start'))
|
||||
->assertSee('immediate_start', false);
|
||||
->assertSee(__('order.terms_link'))
|
||||
->assertSee(__('order.terms_why'))
|
||||
->assertSee(route('legal.agb'), false)
|
||||
->assertSee('terms_accepted', false);
|
||||
});
|
||||
|
||||
it('serves the terms it asks a customer to accept', function () {
|
||||
// A link to a page that says "Dieser Inhalt wird noch ergänzt" is not a
|
||||
// contract, and accepting it would be accepting nothing.
|
||||
$page = $this->get(route('legal.agb'))->assertOk()->getContent();
|
||||
|
||||
expect($page)->toContain('Widerrufsrecht')
|
||||
->and($page)->toContain('Löschfristen')
|
||||
->and($page)->not->toContain('Dieser Inhalt wird noch ergänzt');
|
||||
});
|
||||
|
||||
it('lands as a timestamp on the order, which is what a withdrawal is measured against', function () {
|
||||
|
|
@ -287,20 +300,17 @@ it('records no consent for a session that never carried one', function () {
|
|||
->toBeNull();
|
||||
});
|
||||
|
||||
it('asks for the consent before the packages, not after them', function () {
|
||||
// The consent used to sit at the bottom with every buy button disabled until
|
||||
// it was ticked, so the page arrived looking broken. Read in the order it
|
||||
// applies: the condition first, the choice second — and the button stays
|
||||
// enabled, with the reason it will not go through said next to it.
|
||||
it('asks for the terms before the packages, not after them', function () {
|
||||
// It used to sit at the bottom, so the page arrived with every button
|
||||
// unusable before anybody had done anything wrong. Read in the order it
|
||||
// applies: the condition first, the choice second.
|
||||
$page = Illuminate\Support\Facades\File::get(resource_path('views/livewire/order.blade.php'));
|
||||
|
||||
$consentAt = strpos($page, "__('checkout.immediate_start')");
|
||||
$gridAt = strpos($page, 'lg:grid-cols-3');
|
||||
|
||||
expect($consentAt)->toBeLessThan($gridAt)
|
||||
// No disabled button: a disabled control explains nothing.
|
||||
->and($page)->not->toContain('x-bind:disabled="!immediateStart"')
|
||||
->and($page)->toContain("__('order.consent_first')");
|
||||
expect(strpos($page, "__('order.terms_title')"))->toBeLessThan(strpos($page, 'lg:grid-cols-3'))
|
||||
// No order without the terms — and the button says why rather than
|
||||
// leaving somebody to guess at a control that does nothing.
|
||||
->and($page)->toContain('x-bind:disabled="!terms"')
|
||||
->and($page)->toContain("__('order.terms_first')");
|
||||
});
|
||||
|
||||
it('lays five packages out as three and two', function () {
|
||||
|
|
@ -311,10 +321,11 @@ it('lays five packages out as three and two', function () {
|
|||
});
|
||||
|
||||
it('still refuses the checkout when the box was not ticked', function () {
|
||||
// The layout changed; the rule did not. The server is the gate.
|
||||
// The layout changed, the wording changed, the rule did not: the server is
|
||||
// the gate. Disabling a button in a browser refuses nothing.
|
||||
$this->actingAs(buyer())
|
||||
->post(route('checkout.start'), ['plan' => 'start'])
|
||||
->assertSessionHasErrors('immediate_start');
|
||||
->assertSessionHasErrors('terms_accepted');
|
||||
|
||||
expect($this->stripe->checkouts)->toBeEmpty();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -149,8 +149,12 @@ it('draws the wordmark from one definition, not five', function () {
|
|||
$source = preg_replace('/\{\{--.*?--\}\}/s', '', File::get($file->getPathname()));
|
||||
|
||||
// The wordmark is markup around the name; a plain sentence mentioning
|
||||
// the company is not. `>Clu` catches exactly the lockup forms.
|
||||
if (preg_match('/>\s*Clu(Pilot|<)/', $source)) {
|
||||
// the company is not. `>Clu` with NO whitespace after the tag catches
|
||||
// exactly the lockup forms — the name is the whole content of its
|
||||
// element there. Whitespace used to be allowed, and then the terms page
|
||||
// arrived with a paragraph whose first word is the company name, which
|
||||
// is a sentence and not a brand lockup.
|
||||
if (preg_match('/>Clu(Pilot|<)/', $source)) {
|
||||
$offenders[] = str_replace(resource_path().'/', '', $file->getPathname());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue