Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
commit
880b5f1998
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Registrations nobody ever confirmed, removed.
|
||||
*
|
||||
* An abandoned attempt used to stay in `users` for ever, and it holds the
|
||||
* address hostage while it does: the unique index means the same person cannot
|
||||
* register again with the address they meant. "Abmelden und neu registrieren"
|
||||
* was therefore advice that could not work — signing out frees nothing.
|
||||
*
|
||||
* Five days, because that is long enough for somebody who registered on a
|
||||
* Friday, found the mail in a spam folder on Monday, and clicked it — and short
|
||||
* enough that a typo does not block the correct address for a month.
|
||||
*
|
||||
* What it will NOT touch, and this is the important half:
|
||||
*
|
||||
* - a confirmed account, whatever else is true of it;
|
||||
* - an account with a customer record behind it. A paid checkout creates the
|
||||
* login through the webhook, and such a user can sit unconfirmed while
|
||||
* already having a contract. Deleting that is data loss, not housekeeping.
|
||||
*/
|
||||
class PruneUnverifiedAccounts extends Command
|
||||
{
|
||||
/** How long an unconfirmed registration is kept. */
|
||||
public const AFTER_DAYS = 5;
|
||||
|
||||
protected $signature = 'clupilot:prune-unverified {--dry-run : Only say what would go}';
|
||||
|
||||
protected $description = 'Delete registrations that were never confirmed';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$stale = User::query()
|
||||
->whereNull('email_verified_at')
|
||||
->where('created_at', '<', now()->subDays(self::AFTER_DAYS))
|
||||
// Not a join: the two tables are linked by user_id OR by address
|
||||
// (see Customer::emailTaken — a row can predate or outlive the
|
||||
// other), and either one means somebody is a customer.
|
||||
->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'))
|
||||
->get();
|
||||
|
||||
if ($stale->isEmpty()) {
|
||||
$this->info('Nothing to remove.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
foreach ($stale as $user) {
|
||||
$this->line('would remove: '.$user->email.' (registered '.$user->created_at->toDateString().')');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($stale as $user) {
|
||||
// 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 an unconfirmed registration', [
|
||||
'email' => $user->email,
|
||||
'registered_at' => $user->created_at?->toIso8601String(),
|
||||
]);
|
||||
|
||||
$user->delete();
|
||||
}
|
||||
|
||||
$this->info($stale->count().' unconfirmed registration(s) removed.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DpaVersion;
|
||||
use App\Services\Legal\DpaRenderer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Render the processing agreement and its annex, store them, put them in force.
|
||||
*
|
||||
* The documents are generated from the repository (resources/views/legal/dpa),
|
||||
* so a change to the wording is a change somebody can review in a diff — and the
|
||||
* company name, the sub-processors and the deletion deadlines come from the same
|
||||
* places the rest of the application reads them, which is what stops the
|
||||
* agreement drifting away from what the software does.
|
||||
*
|
||||
* The console's upload form still exists and is unchanged: a lawyer's revision
|
||||
* arrives as a PDF and becomes a version like any other. This command is for the
|
||||
* version this application maintains itself.
|
||||
*/
|
||||
class PublishProcessingAgreement extends Command
|
||||
{
|
||||
protected $signature = 'clupilot:publish-dpa
|
||||
{version : What the document calls itself, e.g. 1.0}
|
||||
{--draft : Store it without putting it in force}';
|
||||
|
||||
protected $description = 'Render the processing agreement and its annex, and put them in force';
|
||||
|
||||
public function handle(DpaRenderer $renderer): int
|
||||
{
|
||||
$version = (string) $this->argument('version');
|
||||
|
||||
if (DpaVersion::query()->where('version', $version)->exists()) {
|
||||
$this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf';
|
||||
$measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf';
|
||||
|
||||
// Visibility "public" is about the FILE MODE, not about the web: this
|
||||
// disk's root is outside the document root and nothing here becomes
|
||||
// reachable by URL. What it changes is 0755/0644 instead of 0700/0600 —
|
||||
// and that is the difference between a document the web process can read
|
||||
// and one it cannot.
|
||||
//
|
||||
// It matters because this command is normally run as root (an artisan
|
||||
// call in a container is root; PHP-FPM is www-data). The first run left
|
||||
// a directory only root could enter, the route's exists() check answered
|
||||
// false, and every link on the page 404'd with nothing in any log to say
|
||||
// why.
|
||||
Storage::disk('local')->put($agreementPath, $renderer->agreement($version), 'public');
|
||||
Storage::disk('local')->put($measuresPath, $renderer->measures($version), 'public');
|
||||
|
||||
$row = DpaVersion::query()->create([
|
||||
'version' => $version,
|
||||
'agreement_path' => $agreementPath,
|
||||
'measures_path' => $measuresPath,
|
||||
// Publishing is the point of the command; --draft is for looking at
|
||||
// it first. Every customer who accepted an earlier version is
|
||||
// outstanding again from this moment.
|
||||
'published_at' => $this->option('draft') ? null : now(),
|
||||
]);
|
||||
|
||||
foreach ([$agreementPath, $measuresPath] as $path) {
|
||||
if (Storage::disk('local')->getVisibility($path) !== 'public') {
|
||||
$this->warn("{$path} is not readable by the web process. Run this as the user PHP runs as, or fix the mode — the page will 404 otherwise.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.');
|
||||
$this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)');
|
||||
$this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function slug(string $version): string
|
||||
{
|
||||
return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version';
|
||||
}
|
||||
}
|
||||
|
|
@ -142,9 +142,27 @@ class SyncStripeCatalogue extends Command
|
|||
$productId = $stripe->createProduct(
|
||||
$family->name,
|
||||
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
|
||||
// Keyed on our row, so a crash between Stripe creating
|
||||
// the product and us storing its id gives back the same
|
||||
// product on the next run rather than a second one.
|
||||
// Covers a RETRY, for twenty-four hours, and nothing
|
||||
// after that: Stripe forgets a key at the end of them.
|
||||
// A crash between Stripe creating the Product and us
|
||||
// storing its id therefore leaves an orphan, and the
|
||||
// next run past the expiry makes a second Product for
|
||||
// this family. There is no recognition step for Products
|
||||
// — App\Services\Billing\AdoptStripePrice covers Prices
|
||||
// only — so nothing here ever asks Stripe what Products
|
||||
// it already has. A KNOWN GAP, not something this key
|
||||
// closes, and a worse one than a duplicate Price:
|
||||
// activePricesFor() would then be asked about the new
|
||||
// Product, and the Price recognition goes blind for the
|
||||
// whole family.
|
||||
//
|
||||
// IdempotencyKey::forProduct() folds the name and the
|
||||
// metadata into what goes on the wire, so a renamed
|
||||
// family under an unstored id now mints a second Product
|
||||
// where it used to answer HTTP 400 for a day. That is
|
||||
// the trade this branch chose deliberately: a blockade
|
||||
// reaches a paying customer, a duplicate Product does
|
||||
// not.
|
||||
idempotencyKey: "clupilot-product-{$family->id}",
|
||||
);
|
||||
$family->update(['stripe_product_id' => $productId]);
|
||||
|
|
@ -168,9 +186,16 @@ class SyncStripeCatalogue extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// "or adopted", because the count is taken BEFORE ensure() runs and
|
||||
// ensure() may find the Price already at Stripe and adopt it instead of
|
||||
// creating anything — see App\Services\Billing\AdoptStripePrice. Telling
|
||||
// which of the two happened is the whole purpose of that step, so the
|
||||
// first thing an operator reads after an interrupted run must not assert
|
||||
// a creation that did not take place. The log entry adoption writes names
|
||||
// the Price it took over.
|
||||
$this->info($dryRun
|
||||
? "{$created} object(s) would be created. Run without --dry-run to create them."
|
||||
: "{$created} object(s) created in Stripe.");
|
||||
? "{$created} object(s) would be created or adopted. Run without --dry-run to do it."
|
||||
: "{$created} object(s) created or adopted in Stripe.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -510,6 +510,21 @@ class LandingController extends Controller
|
|||
// and for them the net figure was simply the wrong number.
|
||||
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
|
||||
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
|
||||
// Paying for a year, in the same three figures the order page
|
||||
// shows: what leaves the account once, what that works out to
|
||||
// per month, and what twelve monthly payments would have been.
|
||||
// A sheet that quotes only the monthly price cannot mention a
|
||||
// saving, and the saving is the reason to offer the term.
|
||||
'price_yearly' => $this->money(
|
||||
$yearlyGross = $this->gross((int) ($plan['yearly_price_cents'] ?? 0)),
|
||||
(string) $plan['currency'],
|
||||
),
|
||||
'price_yearly_net' => $this->money((int) ($plan['yearly_price_cents'] ?? 0), (string) $plan['currency']),
|
||||
// intdiv, so the headline never claims a tenth of a cent nobody
|
||||
// is charged; the total beside it is the amount actually taken.
|
||||
'price_yearly_monthly' => $this->money(intdiv($yearlyGross, 12), (string) $plan['currency']),
|
||||
'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']),
|
||||
'free_months' => (int) ($plan['free_months'] ?? 0),
|
||||
'storage' => $this->storage((int) $plan['quota_gb']),
|
||||
'traffic' => $this->storage((int) $plan['traffic_gb']),
|
||||
'seats' => (int) $plan['seats'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\DpaVersion;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Confirmation before a processing agreement goes into force (R23).
|
||||
*
|
||||
* Publishing is not a save: acceptance is per version, so from this moment every
|
||||
* customer who had accepted the previous one is outstanding again. That is the
|
||||
* correct behaviour and an expensive one to trigger by a misplaced click.
|
||||
*
|
||||
* Mutates nothing itself — it dispatches back to ProcessingAgreements, whose
|
||||
* publish() keeps the permission check it already had.
|
||||
*/
|
||||
class ConfirmPublishDpa extends ModalComponent
|
||||
{
|
||||
public string $uuid;
|
||||
|
||||
public string $version = '';
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
$this->uuid = $uuid;
|
||||
$this->version = $version->version;
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
$this->dispatch('dpa-publish-confirmed', uuid: $this->uuid);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.confirm-publish-dpa');
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,19 @@ class EditCustomer extends ModalComponent
|
|||
|
||||
public string $vatId = '';
|
||||
|
||||
public string $billingAddress = '';
|
||||
/**
|
||||
* The address, field by field — the same four the customer's own settings
|
||||
* page writes. One free-text block here and four fields there would mean an
|
||||
* operator's correction being silently reverted the next time the customer
|
||||
* saved their profile: their fields still held the old values.
|
||||
*/
|
||||
public string $billingStreet = '';
|
||||
|
||||
public string $billingPostcode = '';
|
||||
|
||||
public string $billingCity = '';
|
||||
|
||||
public string $billingCountry = '';
|
||||
|
||||
public string $locale = '';
|
||||
|
||||
|
|
@ -74,7 +86,10 @@ class EditCustomer extends ModalComponent
|
|||
$this->phone = (string) $customer->phone;
|
||||
$this->customerType = (string) $customer->customer_type;
|
||||
$this->vatId = (string) $customer->vat_id;
|
||||
$this->billingAddress = (string) $customer->billing_address;
|
||||
$this->billingStreet = (string) $customer->billing_street;
|
||||
$this->billingPostcode = (string) $customer->billing_postcode;
|
||||
$this->billingCity = (string) $customer->billing_city;
|
||||
$this->billingCountry = (string) $customer->billing_country;
|
||||
$this->locale = (string) $customer->locale;
|
||||
$this->originalEmail = (string) $customer->email;
|
||||
$this->originalVatId = (string) $customer->vat_id;
|
||||
|
|
@ -104,7 +119,10 @@ class EditCustomer extends ModalComponent
|
|||
// cleared back to "nobody asked".
|
||||
'customerType' => ['nullable', Rule::in(Customer::TYPES)],
|
||||
'vatId' => 'nullable|string|max:64',
|
||||
'billingAddress' => 'nullable|string|max:2000',
|
||||
'billingStreet' => 'nullable|string|max:255',
|
||||
'billingPostcode' => 'nullable|string|max:32',
|
||||
'billingCity' => 'nullable|string|max:255',
|
||||
'billingCountry' => 'nullable|string|max:255',
|
||||
'locale' => ['nullable', Rule::in(self::LOCALES)],
|
||||
]);
|
||||
|
||||
|
|
@ -147,7 +165,14 @@ class EditCustomer extends ModalComponent
|
|||
// left alone: it is the record of what was checked, and clearing
|
||||
// it would destroy the evidence rather than the claim.
|
||||
'vat_id' => trim((string) $data['vatId']) ?: null,
|
||||
'billing_address' => trim((string) $data['billingAddress']) ?: null,
|
||||
'billing_street' => trim((string) $data['billingStreet']) ?: null,
|
||||
'billing_postcode' => trim((string) $data['billingPostcode']) ?: null,
|
||||
'billing_city' => trim((string) $data['billingCity']) ?: null,
|
||||
'billing_country' => trim((string) $data['billingCountry']) ?: null,
|
||||
// The composed block, from the same composer the portal uses —
|
||||
// one shape for one field, whichever side writes it. An address
|
||||
// that was never split keeps the block it had.
|
||||
'billing_address' => \App\Livewire\Settings::composeAddress($data) ?: $customer->billing_address,
|
||||
'locale' => $data['locale'] ?: null,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Models\Customer;
|
|||
use App\Models\InboundMail;
|
||||
use App\Services\Mail\IngestInboundMail;
|
||||
use App\Services\Mail\InboundMailbox;
|
||||
use App\Services\Mail\InboundMailStatus;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
|
@ -127,6 +128,11 @@ class Inbox extends Component
|
|||
// explain: a mailbox that was never configured looks exactly like a
|
||||
// mailbox with no mail in it.
|
||||
'configured' => app(InboundMailbox::class)->isConfigured(),
|
||||
// The same line the settings page shows. This is where somebody
|
||||
// notices nothing is arriving, so this is where "last reached" has
|
||||
// to be readable — otherwise an empty inbox and a broken mailbox
|
||||
// look identical.
|
||||
'status' => app(InboundMailStatus::class)->last(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Livewire\Concerns\ConfirmsPassword;
|
|||
use App\Services\Deployment\UpdateChannel;
|
||||
use App\Services\Env\EnvFileEditor;
|
||||
use App\Services\Env\InvalidEnvContentException;
|
||||
use App\Services\Mail\InboundMailbox;
|
||||
use App\Services\Mail\InboundMailStatus;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\OperatingMode;
|
||||
use App\Support\ProvisioningSettings;
|
||||
|
|
@ -429,6 +431,32 @@ class Integrations extends Component
|
|||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach the support mailbox now and say what came back.
|
||||
*
|
||||
* The one thing an operator wants after typing a host, a user and a
|
||||
* password: did any of it work. Waiting for the next scheduled run to find
|
||||
* out is not an answer, and "der Posteingang ist leer" is not one either.
|
||||
*
|
||||
* Saved first, deliberately: testing what is on screen while the server
|
||||
* still holds the previous values would report on a mailbox nobody
|
||||
* configured. The unsaved form is the question being asked.
|
||||
*/
|
||||
public function testInbound(): void
|
||||
{
|
||||
$this->guardInfra();
|
||||
$this->saveInfra();
|
||||
|
||||
$check = app(InboundMailbox::class)->check();
|
||||
app(InboundMailStatus::class)->record($check['ok'], $check['message'], $check['unseen']);
|
||||
|
||||
$this->dispatch('notify', message: $check['ok']
|
||||
? ($check['unseen'] === null
|
||||
? __('integrations.inbound_ok')
|
||||
: trans_choice('integrations.inbound_ok_waiting', $check['unseen'], ['count' => $check['unseen']]))
|
||||
: __('integrations.inbound_failed_'.$check['message']));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$vault = app(SecretVault::class);
|
||||
|
|
@ -487,6 +515,9 @@ class Integrations extends Component
|
|||
self::TABS,
|
||||
fn (string $tab) => $tab !== 'env' || $canSecrets,
|
||||
)),
|
||||
// When the mailbox was last reached, and what came back. Null until
|
||||
// somebody — or the scheduler — has asked once.
|
||||
'inboundStatus' => app(InboundMailStatus::class)->last(),
|
||||
'canSecrets' => $canSecrets,
|
||||
'canInfra' => $canInfra,
|
||||
'unlocked' => $unlocked,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\Mail\MailPreviews;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Look at a mail before a customer does.
|
||||
*
|
||||
* There was no way to. An invoice mail needs an invoice, a maintenance
|
||||
* announcement needs a window, and "register an account to see whether the
|
||||
* confirmation reads well on a phone" is not a workflow — which is how every
|
||||
* mail went out with a hard 600px table until somebody opened one on a
|
||||
* telephone and had to scroll sideways to read a sentence.
|
||||
*
|
||||
* Two ways to look: in the browser, which answers the desktop question at once,
|
||||
* and as a real mail to the operator's OWN address, which is the only way to
|
||||
* answer the phone question. Never to a customer: a preview is for us.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class MailPreview extends Component
|
||||
{
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one to the signed-in operator.
|
||||
*
|
||||
* To themselves and nobody else — no address field. A preview that can be
|
||||
* addressed is a form for sending mail to strangers from inside the console,
|
||||
* which is not what this is for and not something to leave lying about.
|
||||
*/
|
||||
public function sendToMe(string $key): void
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$operator = Auth::guard('operator')->user();
|
||||
$mailable = app(MailPreviews::class)->make($key);
|
||||
|
||||
if ($operator === null || $mailable === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// sendNow(), not send(): every mailable here implements ShouldQueue,
|
||||
// and send() silently defers to queue() for those — which would put
|
||||
// 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.
|
||||
//
|
||||
// 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),
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: __('mail_preview.sent', ['email' => $operator->email]));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->authorize('mail.manage');
|
||||
|
||||
$previews = app(MailPreviews::class)->all();
|
||||
|
||||
return view('livewire.admin.mail-preview', [
|
||||
'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
|
||||
// silently writes to a file.
|
||||
'delivers' => ! in_array(config('mail.default'), ['log', 'array', null], true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Livewire\Concerns\BuildsRunSteps;
|
||||
use App\Mail\Transport\MailboxTransport;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Host;
|
||||
use App\Models\Instance;
|
||||
|
|
@ -11,7 +12,9 @@ use App\Models\ProvisioningRun;
|
|||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\AdminArea;
|
||||
use App\Support\Readiness;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Number;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -369,6 +372,57 @@ class Overview extends Component
|
|||
])];
|
||||
}
|
||||
|
||||
// The site being switched off. It is a deliberate state, not a fault —
|
||||
// but it answers 503 to every visitor who is not on the VPN, and the
|
||||
// only place that says so is the switch itself, three clicks away on
|
||||
// another page. Asked as "ich komme nicht auf www.dev", which is exactly
|
||||
// what it looks like from outside.
|
||||
if (! Settings::bool('site.public', true)) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => __('admin.notice.site_hidden'),
|
||||
'route' => 'admin.settings',
|
||||
];
|
||||
}
|
||||
|
||||
// MAIL_MAILER=log. Every purpose mailer short-circuits to the log
|
||||
// transport then — see MailboxTransport::NON_DELIVERING — however well
|
||||
// the mailboxes are configured, and nothing is delivered to anybody.
|
||||
//
|
||||
// Worth its own notice because the failure is SILENT in every direction:
|
||||
// the queue reports success, no job fails, the console's own mailbox test
|
||||
// reports success (MailboxTester builds its own transport on purpose), and
|
||||
// the mail sits in storage/logs. Somebody registering and waiting for a
|
||||
// verification mail has no way to find that out. Asked exactly that way.
|
||||
if (in_array(config('mail.default'), MailboxTransport::NON_DELIVERING, true)) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]),
|
||||
'route' => 'admin.mail',
|
||||
];
|
||||
}
|
||||
|
||||
// No SITE_HOST. The marketing site is then registered without a
|
||||
// hostname, so it answers on the PORTAL's host as well — and every
|
||||
// route() it generates points at APP_URL, which is why a link on the
|
||||
// site lands on the portal's domain.
|
||||
//
|
||||
// Only where the installation is host-separated at all. On a fresh
|
||||
// checkout nothing is bound to a hostname by design (see
|
||||
// RestrictAdminHost: "upgrading must not lock anyone out of a system
|
||||
// that was working"), and a warning there would be furniture — the same
|
||||
// mistake the capacity notice made before it learned to ask whether
|
||||
// there is a host at all.
|
||||
if (AdminArea::isHostBound() && config('admin_access.site_hosts') === []) {
|
||||
$notices[] = [
|
||||
'level' => 'warning',
|
||||
'text' => __('admin.notice.no_site_host', [
|
||||
'app' => (string) parse_url((string) config('app.url'), PHP_URL_HOST),
|
||||
]),
|
||||
'route' => 'admin.integrations',
|
||||
];
|
||||
}
|
||||
|
||||
// What is not yet set up at all, as opposed to everything above (what
|
||||
// IS set up but currently unhealthy). Readiness::blocking() is the
|
||||
// same list App\Livewire\Admin\Readiness renders in full — this is
|
||||
|
|
|
|||
|
|
@ -53,7 +53,17 @@ class PlanVersions extends Component
|
|||
*/
|
||||
public string $monthlyPrice = '49,00';
|
||||
|
||||
public string $yearlyPrice = '588,00';
|
||||
/**
|
||||
* How many of the twelve months a year costs nothing.
|
||||
*
|
||||
* The yearly TOTAL used to be typed here as a second free-form figure, and
|
||||
* nothing then knew why 588 belonged to a package costing 49 — so no page
|
||||
* could say "two months free" without somebody working it out again by hand.
|
||||
* The total is derived from this (see yearlyCents), which is also the only
|
||||
* arrangement in which the sentence a customer reads and the amount Stripe
|
||||
* charges cannot drift apart.
|
||||
*/
|
||||
public int $freeMonths = 2;
|
||||
|
||||
/** Publishing: when it goes on sale, and optionally when it stops. */
|
||||
public string $availableFrom = '';
|
||||
|
|
@ -85,12 +95,22 @@ class PlanVersions extends Component
|
|||
$this->features = $latest->features ?? [];
|
||||
|
||||
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
|
||||
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
|
||||
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
|
||||
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
|
||||
$this->freeMonths = (int) $latest->free_months;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a year costs: the months that are paid for, at the monthly price.
|
||||
*
|
||||
* One place, static, so the preview on the form and the amount written to
|
||||
* the catalogue are the same arithmetic rather than two copies of it.
|
||||
*/
|
||||
public static function yearlyCents(int $monthlyCents, int $freeMonths): int
|
||||
{
|
||||
return $monthlyCents * (12 - max(0, min(12, $freeMonths)));
|
||||
}
|
||||
|
||||
private function family(): PlanFamily
|
||||
{
|
||||
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
|
||||
|
|
@ -137,11 +157,14 @@ class PlanVersions extends Component
|
|||
$fail(__('plans.price_invalid'));
|
||||
}
|
||||
}],
|
||||
'yearlyPrice' => ['required', 'string', $price],
|
||||
// Nought is a legitimate answer — "a year costs twelve months, pay
|
||||
// it in one go" — and the upper bound stops at six, because eleven
|
||||
// free months is a typo and not a discount.
|
||||
'freeMonths' => 'required|integer|min:0|max:6',
|
||||
]);
|
||||
|
||||
$monthlyCents = Money::toCents($data['monthlyPrice']);
|
||||
$yearlyCents = Money::toCents($data['yearlyPrice']);
|
||||
$yearlyCents = self::yearlyCents((int) $monthlyCents, (int) $data['freeMonths']);
|
||||
|
||||
$version = app(PlanCatalogue::class)->draft(
|
||||
$this->family(),
|
||||
|
|
@ -155,6 +178,7 @@ class PlanVersions extends Component
|
|||
'performance' => $data['performance'],
|
||||
'template_vmid' => $data['templateVmid'],
|
||||
'features' => array_values($data['features']),
|
||||
'free_months' => (int) $data['freeMonths'],
|
||||
],
|
||||
// Both terms, because publishing refuses a version that is not
|
||||
// priced for each — better to find that out here than in front of
|
||||
|
|
@ -317,6 +341,13 @@ class PlanVersions extends Component
|
|||
'featureKeys' => array_keys((array) __('billing.feature')),
|
||||
'performanceClasses' => (array) __('billing.perf'),
|
||||
'currency' => Subscription::catalogueCurrency(),
|
||||
// What a year will cost, worked out here so the form shows the same
|
||||
// figure it is about to write. Null while the monthly price is not a
|
||||
// number yet — the field says so on its own, and a preview of
|
||||
// nothing is worse than no preview.
|
||||
'yearlyPreview' => ($cents = Money::toCents($this->monthlyPrice)) === null
|
||||
? null
|
||||
: self::yearlyCents((int) $cents, $this->freeMonths),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\DpaVersion;
|
||||
use App\Services\Legal\ProcessingAgreement;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* The data-processing agreement: upload a version, publish it, see who has it.
|
||||
*
|
||||
* The TEXT is not this application's and never will be. Art. 28(3) GDPR wants a
|
||||
* contract between the controller (the customer) and the processor (us); what
|
||||
* belongs in it is a lawyer's answer, and inventing one here would be worse than
|
||||
* having none. What this page does is the part software can do properly: keep
|
||||
* the document, say which version is in force, hand it to the customer, and hold
|
||||
* the evidence that they accepted it.
|
||||
*
|
||||
* ## Publishing is what asks every customer again
|
||||
*
|
||||
* Acceptance is per VERSION. Publishing a new one therefore leaves every
|
||||
* customer outstanding until they accept it, which is the correct behaviour and
|
||||
* an expensive one to trigger by accident — so a draft is uploaded first and
|
||||
* published deliberately, in two steps.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class ProcessingAgreements extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** What the document itself calls this version — "1.0", "2026-08". */
|
||||
public string $version = '';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
/** The agreement, and the technical and organisational measures beside it. */
|
||||
public $agreement = null;
|
||||
|
||||
public $measures = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
}
|
||||
|
||||
public function upload(): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$data = $this->validate([
|
||||
'version' => 'required|string|max:64|unique:dpa_versions,version',
|
||||
'note' => 'nullable|string|max:2000',
|
||||
// PDF only, and a real size limit: this is handed to customers as
|
||||
// the contract between us, not as an editable file.
|
||||
'agreement' => 'required|file|mimes:pdf|max:10240',
|
||||
'measures' => 'nullable|file|mimes:pdf|max:10240',
|
||||
]);
|
||||
|
||||
// The private DISK. An agreement is not a public asset, and a guessable
|
||||
// URL to one would be a list of who our customers are — the root of this
|
||||
// disk is outside the document root and nothing here is reachable except
|
||||
// through the two routes that check who is asking.
|
||||
//
|
||||
// The visibility below is the file MODE, not the web: 0755/0644 rather
|
||||
// than 0700/0600, so a document written by one process (an artisan run
|
||||
// as root) stays readable by the other (PHP-FPM as www-data). Getting
|
||||
// that wrong produced a directory nothing could enter and a page whose
|
||||
// every link 404'd silently.
|
||||
$version = DpaVersion::query()->create([
|
||||
'version' => $data['version'],
|
||||
'note' => $data['note'] ?: null,
|
||||
'agreement_path' => $this->agreement->store('dpa', 'local', 'public'),
|
||||
'measures_path' => $this->measures?->store('dpa', 'local', 'public'),
|
||||
'created_by' => Auth::guard('operator')->id(),
|
||||
]);
|
||||
|
||||
$this->reset('version', 'note', 'agreement', 'measures');
|
||||
|
||||
$this->dispatch('notify', message: __('dpa_admin.uploaded', ['version' => $version->version]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a version in force.
|
||||
*
|
||||
* Deliberately separate from the upload: every customer who had accepted the
|
||||
* previous one is outstanding again from this moment, and that is not
|
||||
* something to do by dropping a file on a form.
|
||||
*/
|
||||
#[On('dpa-publish-confirmed')]
|
||||
public function publish(string $uuid): void
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
if ($version->isPublished()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$version->update(['published_at' => now()]);
|
||||
|
||||
$this->dispatch('notify', message: __('dpa_admin.published', ['version' => $version->version]));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->authorize('dpa.manage');
|
||||
|
||||
$current = app(ProcessingAgreement::class)->current();
|
||||
|
||||
// How many customers are outstanding on the version in force. Counted
|
||||
// against customers who are actually still customers — a closed record
|
||||
// is not somebody we are waiting on.
|
||||
$customers = Customer::query()->whereNull('closed_at')->count();
|
||||
$accepted = $current === null ? 0 : $current->acceptances()->count();
|
||||
|
||||
return view('livewire.admin.processing-agreements', [
|
||||
'versions' => DpaVersion::query()->withCount('acceptances')->latest('id')->get(),
|
||||
'current' => $current,
|
||||
'customers' => $customers,
|
||||
'accepted' => $accepted,
|
||||
'outstanding' => max(0, $customers - $accepted),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Whether a stored file is actually there, for the list. */
|
||||
public function exists(?string $path): bool
|
||||
{
|
||||
return $path !== null && Storage::disk('local')->exists($path);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,26 @@
|
|||
|
||||
namespace App\Livewire\Auth;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Where somebody waits between registering and confirming.
|
||||
*
|
||||
* Signed in but not through: the account exists, and nothing in the portal
|
||||
* opens until the address is confirmed. The page therefore has to offer the
|
||||
* only two things that can move somebody forward — send it again, or sign out
|
||||
* and register with the address they meant.
|
||||
* opens until the address is confirmed. The page offers the two things that can
|
||||
* move somebody forward — send it again, or throw the account away and start
|
||||
* over with the address they meant.
|
||||
*
|
||||
* "Abmelden und neu registrieren" used to be the second one, and it was wrong
|
||||
* twice: signing out does not free the address, so registering again with the
|
||||
* same one fails — and every abandoned attempt stays in `users` for ever. The
|
||||
* button deletes the account instead, and anything nobody comes back to is
|
||||
* removed after PruneUnverifiedAccounts::AFTER_DAYS days.
|
||||
*
|
||||
* Fortify would own this page, but 'views' => false: the app renders its own
|
||||
* screens (R1/R2) and Fortify keeps only the POST actions.
|
||||
|
|
@ -70,13 +78,49 @@ class VerifyEmail extends Component
|
|||
$this->dispatch('notify', message: __('verify_email.notice_sent'));
|
||||
}
|
||||
|
||||
public function signOut()
|
||||
/**
|
||||
* Throw this registration away.
|
||||
*
|
||||
* Only an UNCONFIRMED account, and only one nothing is attached to. The
|
||||
* webhook creates a customer's login from a paid checkout, and such a user
|
||||
* can sit here unconfirmed while already having a contract — deleting that
|
||||
* would be data loss dressed up as a convenience. It refuses and says where
|
||||
* to go instead.
|
||||
*
|
||||
* R23: the confirmation is a modal, not a browser dialog. This method is
|
||||
* what the modal's event reaches, and it re-checks everything itself
|
||||
* because a modal is reachable without this page's own guards.
|
||||
*/
|
||||
#[On('own-account-delete-confirmed')]
|
||||
public function deleteAccount()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user === null) {
|
||||
return $this->redirectRoute('login', navigate: false);
|
||||
}
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
// Confirmed accounts are closed, not deleted — that is a different
|
||||
// action, with an invoice history behind it (ConfirmCloseAccount).
|
||||
return $this->redirectRoute('dashboard', navigate: false);
|
||||
}
|
||||
|
||||
if (Customer::query()->where('user_id', $user->getKey())->exists()
|
||||
|| Customer::query()->where('email', $user->email)->exists()) {
|
||||
$this->dispatch('notify', message: __('verify_email.delete_has_contract'));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
$user->delete();
|
||||
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
session()->flash('status', __('verify_email.deleted'));
|
||||
|
||||
return $this->redirectRoute('login', navigate: false);
|
||||
return $this->redirectRoute('register', navigate: false);
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -466,6 +466,12 @@ class Billing extends Component
|
|||
// Per month: the card says "/ month", and a yearly contract stores
|
||||
// the whole year.
|
||||
'price_cents' => $subscription->monthlyPriceCents(),
|
||||
// Which term is running, and what leaves the account when it does.
|
||||
// The figure above is per month either way, so a yearly customer
|
||||
// read a number they never see on a statement and nothing said why.
|
||||
'term' => (string) $subscription->term,
|
||||
'term_total_cents' => (int) $subscription->price_cents,
|
||||
'renews_at' => $subscription->current_period_end,
|
||||
// The portal shows a granted package without a price at all — not
|
||||
// "free", not struck through — so a later conversion to paid does
|
||||
// not read as a negotiation.
|
||||
|
|
@ -548,12 +554,35 @@ class Billing extends Component
|
|||
$customer = $this->customer();
|
||||
$instance = $customer?->instances()->latest('id')->first();
|
||||
$plans = app(PlanCatalogue::class)->sellable();
|
||||
$currentKey = $instance?->plan ?? 'start';
|
||||
|
||||
// The contract, resolved from the CUSTOMER rather than from the
|
||||
// instance: a paid order waiting for a machine has a subscription and no
|
||||
// instance yet (see ReserveResources, which parks it), and asking the
|
||||
// instance would report "no package" at somebody who has paid.
|
||||
$contract = $customer === null ? null : Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'past_due'])
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
// Whether there is anything to show at all. Without this the page fell
|
||||
// back to 'start' for a customer who had bought nothing and rendered it
|
||||
// as "AKTUELLES PAKET Start — Status Aktiv": a contract nobody entered
|
||||
// into, on the page a customer opens to find out what they pay. Reported
|
||||
// straight after registering, and the dashboard said the opposite on the
|
||||
// same account.
|
||||
// An INSTANCE counts too, whatever the subscription rows say: a running
|
||||
// cloud is a contract by any reading a customer cares about, and older
|
||||
// installations have instances whose subscription row predates the
|
||||
// catalogue rebuild.
|
||||
$hasContract = $contract !== null || $instance !== null;
|
||||
|
||||
$currentKey = $instance?->plan ?? $contract?->plan ?? (string) array_key_first($plans);
|
||||
|
||||
// Rank, not price: a grandfathered plan can cost less than a smaller
|
||||
// one does today, and offering that as an "upgrade" would charge a
|
||||
// customer immediately for losing resources.
|
||||
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
|
||||
$currentTier = (int) ($instance?->subscription?->tier ?? $contract?->tier ?? $plans[$currentKey]['tier'] ?? 0);
|
||||
|
||||
// Smaller plans, each with the reason it cannot be taken today. Shown
|
||||
// WITH the reason rather than hidden: a customer who cannot downgrade
|
||||
|
|
@ -577,12 +606,13 @@ class Billing extends Component
|
|||
: collect();
|
||||
|
||||
return view('livewire.billing', [
|
||||
'hasContract' => $hasContract,
|
||||
'currentKey' => $currentKey,
|
||||
// What they HAVE comes from their contract; only what they could
|
||||
// BUY comes from the shop. Reading this off the catalogue showed a
|
||||
// customer today's price as though it were theirs, and dropped the
|
||||
// card entirely once their plan stopped being sold.
|
||||
'current' => $this->currentTerms($instance?->subscription, $instance, $plans[$currentKey] ?? []),
|
||||
'current' => $this->currentTerms($instance?->subscription ?? $contract, $instance, $plans[$currentKey] ?? []),
|
||||
'instance' => $instance,
|
||||
'plans' => $plans,
|
||||
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\SetupFee;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
use App\Services\Provisioning\HostCapacity;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The order, before it is placed.
|
||||
*
|
||||
* There was no such page. The order page posted straight to Stripe, so the last
|
||||
* thing a customer saw in this product's own design was a grid of packages, and
|
||||
* the first thing they saw after pressing was somebody else's payment form
|
||||
* asking for a card. What they were buying, what it costs in total, what happens
|
||||
* afterwards and what they were agreeing to were never stated in one place.
|
||||
*
|
||||
* This is that place: one package, one term, every figure that will be charged,
|
||||
* and the terms acceptance directly above the button that spends money — which
|
||||
* is also where it belongs and where the owner asked for it.
|
||||
*
|
||||
* ## It decides nothing
|
||||
*
|
||||
* Every figure here comes from the same three sources the checkout itself uses —
|
||||
* the catalogue, TaxTreatment, SetupFee — and none of them is recomputed. A
|
||||
* summary that works out its own total is a second answer waiting to disagree
|
||||
* with the first, and the one that matters is the one Stripe is handed.
|
||||
*/
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Checkout extends Component
|
||||
{
|
||||
/** Which package. In the address, so this page can be reloaded and shared. */
|
||||
#[Url]
|
||||
public string $plan = '';
|
||||
|
||||
/** Monthly or yearly — carried from the choice on the order page. */
|
||||
#[Url]
|
||||
public string $term = Subscription::TERM_MONTHLY;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
if (! in_array($this->term, [Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY], true)) {
|
||||
$this->term = Subscription::TERM_MONTHLY;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$customer = $user === null ? null : Customer::query()->where('email', $user->email)->first();
|
||||
|
||||
// Someone who already has a contract does not buy a second one by
|
||||
// accident: changing package is a plan change, which prorates and keeps
|
||||
// their data. Same rule as the order page, checked again here because a
|
||||
// link into this page skips that one.
|
||||
$contract = $customer === null ? null : Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('status', ['active', 'past_due'])
|
||||
->first();
|
||||
|
||||
$tax = TaxTreatment::for($customer);
|
||||
// NOT called `plan` in the view: the public property of that name is a
|
||||
// key, a string, and Livewire merges its properties into the view data —
|
||||
// so a package array under the same name is shadowed by it and every
|
||||
// `$plan['name']` in the template reads an offset off a string.
|
||||
$package = $this->plan === '' ? null : $this->planOf($this->plan);
|
||||
|
||||
return view('livewire.checkout', [
|
||||
'package' => $package,
|
||||
'contract' => $contract,
|
||||
'tax' => $tax,
|
||||
'lines' => $package === null ? [] : $this->lines($package, $tax),
|
||||
'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen package as the shop describes it, or null when the key names
|
||||
* nothing on sale — a URL is a string somebody can retype.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function planOf(string $key): ?array
|
||||
{
|
||||
try {
|
||||
$sellable = app(PlanCatalogue::class)->sellable();
|
||||
} catch (Throwable) {
|
||||
// The catalogue fails loudly by design; a customer's page is not
|
||||
// the place to argue about it. No package, one sentence.
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! array_key_exists($key, $sellable)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plan = $sellable[$key];
|
||||
|
||||
return $plan + [
|
||||
'key' => $key,
|
||||
'delivery' => app(HostCapacity::class)->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What will be charged, line by line, in the order an invoice states it.
|
||||
*
|
||||
* Net, tax and gross come from TaxTreatment rather than from a
|
||||
* multiplication here: a business with a valid EU VAT number outside Austria
|
||||
* pays no Austrian tax at all, and a summary that "adds 20 %" would quote
|
||||
* that customer a total they will never be charged.
|
||||
*
|
||||
* @param array<string, mixed> $plan
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lines(array $plan, TaxTreatment $tax): array
|
||||
{
|
||||
$yearly = $this->term === Subscription::TERM_YEARLY;
|
||||
|
||||
$recurringNet = (int) ($yearly ? $plan['yearly_price_cents'] : $plan['price_cents']);
|
||||
$recurringGross = $tax->chargeCents($recurringNet);
|
||||
$setupGross = SetupFee::chargedCents($tax);
|
||||
$setupNet = SetupFee::netCents();
|
||||
|
||||
return [
|
||||
'currency' => (string) $plan['currency'],
|
||||
'recurring_net' => $recurringNet,
|
||||
'recurring_gross' => $recurringGross,
|
||||
'setup_net' => $setupNet,
|
||||
'setup_gross' => $setupGross,
|
||||
// What leaves the account on the first charge: the term plus the
|
||||
// one-off. Every figure afterwards is the term alone.
|
||||
'today_net' => $recurringNet + $setupNet,
|
||||
'today_gross' => $recurringGross + $setupGross,
|
||||
'today_tax' => ($recurringGross + $setupGross) - ($recurringNet + $setupNet),
|
||||
'yearly' => $yearly,
|
||||
'free_months' => (int) ($plan['free_months'] ?? 0),
|
||||
// Only on the yearly view, and only when there is something to
|
||||
// compare against: twelve monthly charges at the monthly price.
|
||||
'twelve_months_gross' => $tax->chargeCents((int) $plan['price_cents']) * 12,
|
||||
'per_month_gross' => $yearly ? intdiv($recurringGross, 12) : $recurringGross,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
|
|||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -46,6 +47,12 @@ class ConfirmCancelPackage extends ModalComponent
|
|||
|
||||
public string $confirmName = '';
|
||||
|
||||
/** Why. One of Subscription::CANCEL_REASONS. */
|
||||
public string $reason = '';
|
||||
|
||||
/** What they wanted to say about it — required when the reason is "other". */
|
||||
public string $note = '';
|
||||
|
||||
public function cancelPackage()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
|
@ -68,6 +75,22 @@ class ConfirmCancelPackage extends ModalComponent
|
|||
return;
|
||||
}
|
||||
|
||||
// Asked, and required. A departure with no reason on it is the one fact
|
||||
// about it worth having, and "we can ask them later" means never: the
|
||||
// customer is here now and never comes back to this form.
|
||||
if (! in_array($this->reason, Subscription::CANCEL_REASONS, true)) {
|
||||
$this->addError('reason', __('settings.cancel_reason_required'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// "Sonstiges" with nothing beside it is the same as no answer at all.
|
||||
if ($this->reason === 'other' && trim($this->note) === '') {
|
||||
$this->addError('note', __('settings.cancel_note_required'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$contract = $this->contractFor($customer, $instance);
|
||||
|
||||
if (! $this->stopBilling($contract)) {
|
||||
|
|
@ -86,7 +109,11 @@ class ConfirmCancelPackage extends ModalComponent
|
|||
// half of the application can tell a cancelled contract from an untouched
|
||||
// one — and the status deliberately stays `active`, because until the term
|
||||
// runs out this is a paying customer.
|
||||
$contract?->update(['cancel_requested_at' => now()]);
|
||||
$contract?->update([
|
||||
'cancel_requested_at' => now(),
|
||||
'cancel_reason' => $this->reason,
|
||||
'cancel_reason_note' => trim($this->note) ?: null,
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('settings', navigate: true);
|
||||
}
|
||||
|
|
@ -200,10 +227,26 @@ class ConfirmCancelPackage extends ModalComponent
|
|||
|
||||
public function render()
|
||||
{
|
||||
$instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first();
|
||||
$customer = $this->customer();
|
||||
$instance = $customer?->instances()->where('status', 'active')->latest('id')->first();
|
||||
|
||||
// Whether withdrawing is still open to them. Offered as a way OUT of
|
||||
// this dialogue rather than as a reason inside it: a withdrawal is a
|
||||
// different act with a different consequence — the whole amount back and
|
||||
// the service ending the same day — and somebody who is entitled to one
|
||||
// should not cancel by mistake and lose it. WithdrawalRight answers for
|
||||
// a business customer as well: it says no.
|
||||
// contractFor() needs an instance; with none there is nothing to
|
||||
// withdraw from either, and WithdrawalRight answers null with "no".
|
||||
$withdrawal = WithdrawalRight::for(
|
||||
$instance === null ? null : $this->contractFor($customer, $instance)
|
||||
);
|
||||
|
||||
return view('livewire.confirm-cancel-package', [
|
||||
'subdomain' => $instance?->subdomain ?? '',
|
||||
'reasons' => Subscription::CANCEL_REASONS,
|
||||
'canWithdraw' => $withdrawal->applies && $withdrawal->open,
|
||||
'withdrawalEndsAt' => $withdrawal->endsAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* "Diese Registrierung wirklich verwerfen?"
|
||||
*
|
||||
* R23: a consequence is confirmed in this product's own design, never in a
|
||||
* window the browser draws. The modal mutates nothing — it dispatches the event
|
||||
* the page component listens for, so the checks that decide whether the account
|
||||
* may be deleted stay in the one place they already are (Auth\VerifyEmail),
|
||||
* rather than being duplicated here where they could drift.
|
||||
*/
|
||||
class ConfirmDeleteOwnAccount extends ModalComponent
|
||||
{
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->dispatch('own-account-delete-confirmed');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.confirm-delete-own-account');
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,12 @@ use Throwable;
|
|||
* purchase becomes real on the webhook, which is the only place allowed to
|
||||
* believe a payment happened.
|
||||
*/
|
||||
#[Layout('layouts.portal')]
|
||||
// layouts.portal is the bare shell the SIGN-IN pages use: no navigation, no
|
||||
// page padding. This page is reached signed in and behind `verified`, so it
|
||||
// belongs in the app shell like every other portal page — Dashboard and Billing
|
||||
// were already there. In the wrong one it rendered flush against the top and
|
||||
// bottom of the window with no way back to anything.
|
||||
#[Layout('layouts.portal-app')]
|
||||
class Order extends Component
|
||||
{
|
||||
public function render()
|
||||
|
|
@ -100,6 +105,9 @@ class Order extends Component
|
|||
|
||||
foreach ($sellable as $key => $plan) {
|
||||
$net = (int) $plan['price_cents'];
|
||||
$yearlyNet = (int) ($plan['yearly_price_cents'] ?? $net * 12);
|
||||
$yearlyGross = $tax->chargeCents($yearlyNet);
|
||||
$freeMonths = (int) ($plan['free_months'] ?? 0);
|
||||
|
||||
$plans[] = [
|
||||
'key' => $key,
|
||||
|
|
@ -107,6 +115,16 @@ class Order extends Component
|
|||
'audience' => (string) ($plan['audience'] ?? ''),
|
||||
'gross' => $tax->chargeCents($net),
|
||||
'net' => $net,
|
||||
// Paying for a year, in the three figures somebody compares:
|
||||
// what leaves the account once, what that works out to per
|
||||
// month, and what it would have cost month by month.
|
||||
'yearly_gross' => $yearlyGross,
|
||||
'yearly_net' => $yearlyNet,
|
||||
// intdiv, so the headline figure never claims a tenth of a cent
|
||||
// nobody is charged. The total above it is the amount taken.
|
||||
'yearly_per_month' => intdiv($yearlyGross, 12),
|
||||
'twelve_months_gross' => $tax->chargeCents($net) * 12,
|
||||
'free_months' => $freeMonths,
|
||||
'currency' => (string) $plan['currency'],
|
||||
'quota_gb' => (int) $plan['quota_gb'],
|
||||
'traffic_gb' => (int) $plan['traffic_gb'],
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Livewire\Concerns\ResolvesCustomer;
|
|||
use App\Models\Customer;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\WithdrawalRight;
|
||||
use App\Services\Legal\ProcessingAgreement;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -19,16 +20,52 @@ use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
|||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The customer's own settings.
|
||||
*
|
||||
* It was one column, 768 pixels wide inside a 1240-pixel shell — a third of the
|
||||
* window left empty beside it — and everything a customer might ever change was
|
||||
* stacked in it: the company address, their password, two-factor, the devices
|
||||
* they are signed in on, the branding of their cloud, the contract, and the
|
||||
* button that closes the account. Two thousand pixels of scroll, and the way to
|
||||
* a particular thing was to know how far down it lived.
|
||||
*
|
||||
* Four tabs now, by what somebody came to do: their data, how the account is
|
||||
* protected, how the cloud looks, and the contract. The choice lives in the
|
||||
* query string, so a reload, a bookmark and the back button all land where they
|
||||
* were — and a link to this page can name the part it means.
|
||||
*/
|
||||
class Settings extends Component
|
||||
{
|
||||
use ChangesOwnPassword;
|
||||
use ConfirmsPassword;
|
||||
|
||||
/**
|
||||
* The tabs, in the order they are shown.
|
||||
*
|
||||
* The list IS the schema: it validates the query string, builds the tab bar
|
||||
* and decides which section renders. A fourth place to add a tab is a fourth
|
||||
* place to forget one.
|
||||
*/
|
||||
public const TABS = ['profile', 'security', 'branding', 'contract'];
|
||||
|
||||
/**
|
||||
* Which one is open, taken from and written back to the address.
|
||||
*
|
||||
* `history: true`, so each tab is a step the back button can take, and no
|
||||
* `except`, so the parameter is there from the first render — hiding it on
|
||||
* the default reads as "the tab is not in the URL", which is exactly how it
|
||||
* was reported on the console's settings page.
|
||||
*/
|
||||
#[Url(history: true)]
|
||||
public string $tab = 'profile';
|
||||
|
||||
/** Shown once, right after setting two-factor up. */
|
||||
public ?array $recoveryCodes = null;
|
||||
|
||||
|
|
@ -138,8 +175,29 @@ class Settings extends Component
|
|||
#[Validate('nullable|string|max:64')]
|
||||
public string $vatId = '';
|
||||
|
||||
#[Validate('nullable|string|max:2000')]
|
||||
public string $billingAddress = '';
|
||||
/**
|
||||
* 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')]
|
||||
|
|
@ -158,6 +216,13 @@ class Settings extends Component
|
|||
|
||||
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;
|
||||
|
|
@ -167,7 +232,10 @@ class Settings extends Component
|
|||
$this->phone = $c->phone ?? '';
|
||||
$this->customerType = $c->customer_type ?? '';
|
||||
$this->vatId = $c->vat_id ?? '';
|
||||
$this->billingAddress = $c->billing_address ?? '';
|
||||
$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 ?? '';
|
||||
|
|
@ -191,25 +259,61 @@ class Settings extends Component
|
|||
// who never touched this field — but no third value can ever get in.
|
||||
'customerType' => ['nullable', Rule::in(Customer::TYPES)],
|
||||
'vatId' => 'nullable|string|max:64',
|
||||
'billingAddress' => 'nullable|string|max:2000',
|
||||
'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,
|
||||
// Never cleared back to "unrecorded" from here. Once somebody has
|
||||
// answered, the answer stands until it is replaced by the other one:
|
||||
// a form submitted with the field blank must not quietly undo a
|
||||
// recorded consumer into an unknown.
|
||||
'customer_type' => $data['customerType'] ?: $c->customer_type,
|
||||
// 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_address' => $data['billingAddress'] ?: 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<string, mixed> $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.
|
||||
*
|
||||
|
|
@ -293,6 +397,32 @@ class Settings extends Component
|
|||
* 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
|
||||
{
|
||||
|
|
@ -358,6 +488,7 @@ class Settings extends Component
|
|||
$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.
|
||||
|
|
@ -378,6 +509,11 @@ class Settings extends Component
|
|||
// 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ class Customer extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'user_id', 'name', 'contact_name', 'email', 'customer_type', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
|
||||
'billing_street', 'billing_postcode', 'billing_city', 'billing_country',
|
||||
'locale', 'stripe_customer_id', 'status', 'closed_at',
|
||||
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* That a customer accepted a particular version, when, and from where.
|
||||
*
|
||||
* Evidence, so nothing here is ever updated: a new version means a new row. What
|
||||
* makes it evidence rather than a flag is the three fields beside the link — the
|
||||
* moment, the address it came from, and the login that pressed the button.
|
||||
*/
|
||||
class DpaAcceptance extends Model
|
||||
{
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = ['customer_id', 'dpa_version_id', 'user_id', 'ip', 'accepted_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['accepted_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function version(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DpaVersion::class, 'dpa_version_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* One published version of the data-processing agreement.
|
||||
*
|
||||
* The TEXT is not this application's. An operator uploads the document their
|
||||
* lawyer wrote, names the version, and publishes it; everything here is about
|
||||
* serving that file and knowing which one is in force. The technical and
|
||||
* organisational measures ride along as a second file rather than as a second
|
||||
* concept — they are an annex to the agreement, and "which measures applied when
|
||||
* this customer accepted" has to have one answer.
|
||||
*/
|
||||
class DpaVersion extends Model
|
||||
{
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = ['version', 'agreement_path', 'measures_path', 'note', 'published_at', 'created_by'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['published_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The version in force: the newest published one.
|
||||
*
|
||||
* Newest by publication, not by id — a version prepared earlier and
|
||||
* published later is the later one, and the id says nothing about that.
|
||||
*/
|
||||
public static function current(): ?self
|
||||
{
|
||||
return static::query()
|
||||
->whereNotNull('published_at')
|
||||
->where('published_at', '<=', now())
|
||||
->latest('published_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function isPublished(): bool
|
||||
{
|
||||
return $this->published_at !== null && $this->published_at->isPast();
|
||||
}
|
||||
|
||||
public function acceptances(): HasMany
|
||||
{
|
||||
return $this->hasMany(DpaAcceptance::class);
|
||||
}
|
||||
|
||||
public function operator()
|
||||
{
|
||||
return $this->belongsTo(Operator::class, 'created_by');
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ class PlanVersion extends Model
|
|||
public const FROZEN = [
|
||||
'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
|
||||
'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
|
||||
// How many of the twelve months a year costs nothing. Frozen with the
|
||||
// rest: it is not presentation, it is what the customer bought — and the
|
||||
// yearly amount in `plan_prices` was derived from it.
|
||||
'free_months',
|
||||
];
|
||||
|
||||
protected $guarded = [];
|
||||
|
|
@ -156,6 +160,11 @@ class PlanVersion extends Model
|
|||
'quota_gb' => $this->quota_gb,
|
||||
'traffic_gb' => $this->traffic_gb,
|
||||
'seats' => $this->seats,
|
||||
// Part of the terms, so it travels with them: every reader of the
|
||||
// catalogue — the shop, the order page, the console — gets the
|
||||
// figure from the version rather than dividing two prices and
|
||||
// hoping the answer is whole.
|
||||
'free_months' => (int) $this->free_months,
|
||||
'ram_mb' => $this->ram_mb,
|
||||
'cores' => $this->cores,
|
||||
'disk_gb' => $this->disk_gb,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,31 @@ class Subscription extends Model
|
|||
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* Why a customer cancelled, as a fixed list.
|
||||
*
|
||||
* A free-text box alone answers nothing that can be counted, and "sonstiges"
|
||||
* with a sentence beside it is worth more than eight boxes nobody ticks
|
||||
* honestly. Keys, not sentences: the wording lives in the settings language
|
||||
* files and can be rewritten without rewriting what is on record. (Written
|
||||
* that way on purpose — a path with a star and a slash in it ends the
|
||||
* docblock it is standing in.)
|
||||
*
|
||||
* `withdrawal` is deliberately NOT here. Withdrawing is a different act with
|
||||
* a different consequence — the whole amount back and the service ending the
|
||||
* same day — and it is recorded by WithdrawContract, not by a cancellation
|
||||
* carrying a reason that says it was one.
|
||||
*/
|
||||
public const CANCEL_REASONS = [
|
||||
'too_expensive',
|
||||
'missing_features',
|
||||
'switching_provider',
|
||||
'no_longer_needed',
|
||||
'performance',
|
||||
'support',
|
||||
'other',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(function (self $subscription) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ use Illuminate\Database\UniqueConstraintViolationException;
|
|||
*/
|
||||
final class AddonPrices
|
||||
{
|
||||
public function __construct(private readonly StripeClient $stripe) {}
|
||||
public function __construct(
|
||||
private readonly StripeClient $stripe,
|
||||
private readonly AdoptStripePrice $adopt,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The net a whole term of this module costs.
|
||||
|
|
@ -125,12 +128,7 @@ final class AddonPrices
|
|||
|
||||
$productId = $this->product($addonKey);
|
||||
|
||||
$priceId = $this->stripe->createPrice(
|
||||
productId: $productId,
|
||||
amountCents: $amount,
|
||||
currency: $currency,
|
||||
interval: $interval,
|
||||
metadata: [
|
||||
$metadata = [
|
||||
// Read back when a Stripe invoice line has to be turned into
|
||||
// wording a customer can read — see StripeInvoiceLines.
|
||||
'addon' => $addonKey,
|
||||
|
|
@ -138,17 +136,37 @@ final class AddonPrices
|
|||
// Stripe's own dashboard, where they would otherwise differ only
|
||||
// by an amount.
|
||||
'tax_treatment' => $reverseCharge ? 'reverse_charge' : 'domestic',
|
||||
],
|
||||
// Keyed on what the Price IS, so a crash between Stripe creating it
|
||||
// and us storing its id gives back the same Price on the next
|
||||
// attempt rather than a second one at the same money. The amount is
|
||||
// the CHARGED one, so the move from net to gross does not replay the
|
||||
// net Price the old key was minted under — and the treatment is in
|
||||
// there because at a rate of nought the two Prices are the same
|
||||
// amount: one key would have Stripe hand the same object back for
|
||||
// both, and the two rows would then share a Price, so archiving the
|
||||
// gross one at the next rate change would withdraw the very Price the
|
||||
// net side is still selling.
|
||||
];
|
||||
|
||||
// Asked BEFORE minting, because a run that died between Stripe's create
|
||||
// and our insert left a Price no table of ours knows — and the key below
|
||||
// stops protecting it after twenty-four hours. See AdoptStripePrice for
|
||||
// what may be taken over and why so narrowly.
|
||||
$priceId = ($this->adopt)(
|
||||
productId: $productId,
|
||||
amountCents: $amount,
|
||||
currency: $currency,
|
||||
interval: $interval,
|
||||
metadata: $metadata,
|
||||
identifying: ['addon'],
|
||||
claimed: fn (string $id) => StripeAddonPrice::query()
|
||||
->where('stripe_price_id', $id)
|
||||
->exists(),
|
||||
);
|
||||
|
||||
$priceId ??= $this->stripe->createPrice(
|
||||
productId: $productId,
|
||||
amountCents: $amount,
|
||||
currency: $currency,
|
||||
interval: $interval,
|
||||
metadata: $metadata,
|
||||
// Says "I have already sent this call", nothing more. The amount is
|
||||
// the CHARGED one and the treatment is in there because at a rate of
|
||||
// nought the two Prices are the same amount — but what stops a
|
||||
// second Price for one figure is AdoptStripePrice above, not this:
|
||||
// Stripe forgets a key after twenty-four hours. The metadata is
|
||||
// folded in by IdempotencyKey inside the client, so changing the
|
||||
// format above can never again refuse the call for a day.
|
||||
idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}"
|
||||
.($reverseCharge ? '-rc' : ''),
|
||||
);
|
||||
|
|
@ -269,6 +287,11 @@ final class AddonPrices
|
|||
return $this->stripe->createProduct(
|
||||
app(AddonCatalogue::class)->name($addonKey),
|
||||
['addon' => $addonKey],
|
||||
// A retry's guard for twenty-four hours and nothing beyond them, the
|
||||
// same as the plan side's in SyncStripeCatalogue — and the same known
|
||||
// gap: there is no recognition step for Products, so a run that
|
||||
// created this one and died before any row carried its id leaves an
|
||||
// orphan nothing here will ever ask Stripe about.
|
||||
idempotencyKey: "clupilot-addon-product-{$addonKey}",
|
||||
);
|
||||
}
|
||||
|
|
@ -298,6 +321,28 @@ final class AddonPrices
|
|||
// Two bookings of the same module landed together. Stripe replayed
|
||||
// one Price for both — the idempotency key saw to that — so there is
|
||||
// nothing to correct here beyond letting the first row stand.
|
||||
//
|
||||
// Since the unique index on `stripe_price_id` below, this catch has a
|
||||
// SECOND meaning: another TUPLE claims this Price id, which is two
|
||||
// callers adopting the same orphan at once. The outcome then is no row
|
||||
// for this tuple at all, not "the first row stands" — the row that
|
||||
// exists belongs to the other tuple. That self-heals on the next
|
||||
// ensure(): the orphan is claimed, so adoption refuses it and
|
||||
// createPrice() mints this tuple its own Price. No money moves in the
|
||||
// meantime, because both callers matched the same `unit_amount`
|
||||
// before adopting; what a caller gets back here is the Price id it
|
||||
// asked for, and it charges what was asked.
|
||||
//
|
||||
// What keeps two ROWS off one Price id is the `claimed` callback in
|
||||
// ensure(), asked before minting. The unique index on
|
||||
// `stripe_price_id` (`stripe_addon_prices_price_unique`, since
|
||||
// 2026_07_31_210000_one_row_per_stripe_price, matching
|
||||
// `stripe_plan_prices.stripe_price_id` on the plan side) is the net
|
||||
// UNDER that callback, not a substitute for it — two rows sharing one
|
||||
// Price would be worse than the ordinary duplicate this catch
|
||||
// handles: archiving one — the ordinary response to a superseded
|
||||
// figure — would then withdraw the very Price the other row is still
|
||||
// selling.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Services\Stripe\StripeClient;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* The Stripe Price we were about to create and Stripe already has.
|
||||
*
|
||||
* An abandoned run leaves an orphan: Stripe made the Price, our insert never
|
||||
* happened, and the table that decides everything afterwards does not know it
|
||||
* exists. The idempotency key covers the next twenty-four hours; after that it
|
||||
* covers nothing, and the run that follows makes a SECOND live Price for the
|
||||
* same money — precisely the duplicate the key was there to prevent. It happened
|
||||
* on 2026-07-29 with priority_support at 3480 EUR.
|
||||
*
|
||||
* So before minting, we ask. Nothing else in the catalogue ever did.
|
||||
*
|
||||
* **What may be taken over, and why so narrowly.** Same amount, same currency,
|
||||
* same interval — a Price at another figure would move money, and moving money
|
||||
* is what this must never do: a running contract keeps the Price it was sold on
|
||||
* and every booking stays frozen at what it cost that day. Then proof: the
|
||||
* candidate must not contradict our metadata, and it must confirm at least one
|
||||
* identifying key of it. An active Price at the right money on our own Product
|
||||
* with no metadata at all is what a person clicking through Stripe's dashboard
|
||||
* leaves behind, and adopting that would be worse than minting a second one.
|
||||
* Finally it must be unclaimed — a Price id belongs to one row, which is what
|
||||
* the unique index on both registers now enforces.
|
||||
*
|
||||
* **Several orphans.** The oldest is adopted, because it is the one a lost row
|
||||
* is likeliest to have been billing on, and the others are archived at Stripe.
|
||||
* Archiving stops a Price being SOLD and leaves every subscription on it exactly
|
||||
* where it was — the same grandfathering archiveSuperseded() has always relied
|
||||
* on — so the rule "one live Price per figure" is restored without touching a
|
||||
* single contract.
|
||||
*
|
||||
* **Reported through the log, not the console.** AddonPrices::ensure() runs
|
||||
* inside a customer's module booking as well as in the sweep, and there is no
|
||||
* command line there.
|
||||
*/
|
||||
final class AdoptStripePrice
|
||||
{
|
||||
public function __construct(private readonly StripeClient $stripe) {}
|
||||
|
||||
/**
|
||||
* The id of an existing Stripe Price to use instead of creating one, or null.
|
||||
*
|
||||
* @param array<string, string> $metadata what the create call would send
|
||||
* @param array<int, string> $identifying metadata keys that mark a Price as ours
|
||||
* @param callable(string): bool $claimed is this Price id already in our register?
|
||||
*/
|
||||
public function __invoke(
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata,
|
||||
array $identifying,
|
||||
callable $claimed,
|
||||
): ?string {
|
||||
// Stripe hands metadata back as strings. An un-cast int here would then
|
||||
// never satisfy confirms()'s strict ===, silently disabling adoption for
|
||||
// that Price forever and warning on every sweep and every booking. One
|
||||
// cast here makes the class independent of what a caller happens to pass.
|
||||
$metadata = array_map(fn ($value) => (string) $value, $metadata);
|
||||
|
||||
$candidates = [];
|
||||
|
||||
foreach ($this->stripe->activePricesFor($productId) as $price) {
|
||||
if ($price['unit_amount'] !== $amountCents
|
||||
|| $price['currency'] !== strtoupper($currency)
|
||||
|| $price['interval'] !== $interval) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($claimed($price['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Contradicts on something it carries: another Price of ours, not a
|
||||
// mystery. Silent — at a VAT rate of nought the two treatments share
|
||||
// an amount, and this would otherwise warn on every sweep.
|
||||
if ($this->contradicts($price['metadata'], $metadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->confirms($price['metadata'], $metadata, $identifying)) {
|
||||
Log::warning('stripe: left an unexplained active price alone rather than adopting it', [
|
||||
'price' => $price['id'],
|
||||
'product' => $productId,
|
||||
'amount_cents' => $amountCents,
|
||||
'currency' => strtoupper($currency),
|
||||
'interval' => $interval,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = $price;
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, fn (array $a, array $b) => $a['created'] <=> $b['created']);
|
||||
|
||||
$adopted = array_shift($candidates);
|
||||
|
||||
foreach ($candidates as $duplicate) {
|
||||
$this->stripe->archivePrice($duplicate['id']);
|
||||
|
||||
Log::warning('stripe: stopped selling a duplicate price for one figure', [
|
||||
'price' => $duplicate['id'],
|
||||
'adopted' => $adopted['id'],
|
||||
'product' => $productId,
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
}
|
||||
|
||||
// Only when it differs, so a sweep over a healthy catalogue makes no
|
||||
// writes at Stripe at all. Compared through array_intersect_key() rather
|
||||
// than a bare !==, for two reasons: Stripe MERGES a metadata write rather
|
||||
// than replacing it, so a Price can carry a key of its own that a write
|
||||
// would never remove and a bare !== would then never call equal — and
|
||||
// PHP's array !== is key-order sensitive, so Stripe handing the same
|
||||
// values back in a different order would trigger a write every single
|
||||
// sweep. ksort() on both sides settles the order; the intersect settles
|
||||
// the extra key, by looking only at what WE would send.
|
||||
$overlap = array_intersect_key($adopted['metadata'], $metadata);
|
||||
$wanted = $metadata;
|
||||
ksort($overlap);
|
||||
ksort($wanted);
|
||||
|
||||
if ($overlap !== $wanted) {
|
||||
$this->stripe->updatePriceMetadata($adopted['id'], $metadata);
|
||||
}
|
||||
|
||||
Log::info('stripe: adopted an existing price instead of creating a second one', [
|
||||
'price' => $adopted['id'],
|
||||
'product' => $productId,
|
||||
'amount_cents' => $amountCents,
|
||||
'currency' => strtoupper($currency),
|
||||
'interval' => $interval,
|
||||
]);
|
||||
|
||||
return $adopted['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Price say something about itself that we do not?
|
||||
*
|
||||
* @param array<string, string> $found
|
||||
* @param array<string, string> $expected
|
||||
*/
|
||||
private function contradicts(array $found, array $expected): bool
|
||||
{
|
||||
foreach ($expected as $key => $value) {
|
||||
if (array_key_exists($key, $found) && $found[$key] !== $value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does it prove it is ours?
|
||||
*
|
||||
* Failing to contradict is not enough — an empty metadata bag contradicts
|
||||
* nothing. At least one identifying key has to be there and agree.
|
||||
*
|
||||
* @param array<string, string> $found
|
||||
* @param array<string, string> $expected
|
||||
* @param array<int, string> $identifying
|
||||
*/
|
||||
private function confirms(array $found, array $expected, array $identifying): bool
|
||||
{
|
||||
foreach ($identifying as $key) {
|
||||
if (isset($found[$key]) && $found[$key] === ($expected[$key] ?? null)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,10 +70,18 @@ final class PlanCatalogue
|
|||
}
|
||||
|
||||
$monthly = $priced[Subscription::TERM_MONTHLY];
|
||||
$yearly = $priced[Subscription::TERM_YEARLY];
|
||||
|
||||
return [$family->key => array_merge($version->capabilities(), [
|
||||
'name' => $family->name,
|
||||
'price_cents' => $monthly->amount_cents,
|
||||
// The yearly figure travels with the monthly one. Both terms
|
||||
// are already required before a version can be sold at all
|
||||
// (requiredPrices above), so a shop showing one and hiding
|
||||
// the other was hiding a price it had in its hand — and
|
||||
// whoever wanted to show it would have gone looking for a
|
||||
// second source.
|
||||
'yearly_price_cents' => $yearly->amount_cents,
|
||||
'currency' => $monthly->currency,
|
||||
'plan_version_id' => $version->id,
|
||||
// Marketing presentation, not a capability: it lives on the
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ use App\Services\Stripe\StripeClient;
|
|||
*/
|
||||
final class PlanPrices
|
||||
{
|
||||
public function __construct(private readonly StripeClient $stripe) {}
|
||||
public function __construct(
|
||||
private readonly StripeClient $stripe,
|
||||
private readonly AdoptStripePrice $adopt,
|
||||
) {}
|
||||
|
||||
/** What Stripe is asked to take for this row from a customer treated so. */
|
||||
public static function chargedCents(PlanPrice $price, TaxTreatment $treatment): int
|
||||
|
|
@ -131,14 +134,10 @@ final class PlanPrices
|
|||
$existing = $this->registered($price, $treatment, includeArchived: true);
|
||||
|
||||
$priceId = $existing?->stripe_price_id;
|
||||
$interval = $price->term === Subscription::TERM_YEARLY ? 'year' : 'month';
|
||||
|
||||
if ($priceId === null) {
|
||||
$priceId = $this->stripe->createPrice(
|
||||
productId: (string) $productId,
|
||||
amountCents: $charged,
|
||||
currency: (string) $price->currency,
|
||||
interval: $price->term === Subscription::TERM_YEARLY ? 'year' : 'month',
|
||||
metadata: [
|
||||
$metadata = [
|
||||
'plan_family' => $family->key,
|
||||
'plan_version' => (string) $version->version,
|
||||
'plan_version_id' => (string) $version->id,
|
||||
|
|
@ -148,13 +147,43 @@ final class PlanPrices
|
|||
// Stripe's own dashboard, where two Prices on one Product
|
||||
// would otherwise differ only by an amount.
|
||||
'tax_treatment' => $treatment->reverseCharge ? 'reverse_charge' : 'domestic',
|
||||
],
|
||||
// The CHARGED amount is part of the key, so a run after a rate
|
||||
// change cannot replay the Price minted at the old figure. So is
|
||||
// the treatment, and it has to be: at a rate of nought the two
|
||||
// Prices are the same amount, and one key would have Stripe hand
|
||||
// back the same object for both — which the register, where a
|
||||
// Price id is unique, would refuse to record twice.
|
||||
];
|
||||
|
||||
// Asked BEFORE minting: a run that died between Stripe's create and
|
||||
// our insert left a Price the register does not know, and the key
|
||||
// below stops protecting it after twenty-four hours. `plan_price_id`
|
||||
// is what proves such a Price is this row's — one Product carries
|
||||
// every version and term of a family, so the amount alone would not.
|
||||
$priceId = ($this->adopt)(
|
||||
productId: (string) $productId,
|
||||
amountCents: $charged,
|
||||
currency: (string) $price->currency,
|
||||
interval: $interval,
|
||||
metadata: $metadata,
|
||||
identifying: ['plan_price_id'],
|
||||
claimed: fn (string $id) => StripePlanPrice::query()
|
||||
->where('stripe_price_id', $id)
|
||||
->exists(),
|
||||
);
|
||||
|
||||
$priceId ??= $this->stripe->createPrice(
|
||||
productId: (string) $productId,
|
||||
amountCents: $charged,
|
||||
currency: (string) $price->currency,
|
||||
interval: $interval,
|
||||
metadata: $metadata,
|
||||
// Says "I have already sent this call", nothing more. The
|
||||
// CHARGED amount is part of the key, so a run after a rate
|
||||
// change cannot replay the Price minted at the old figure. So
|
||||
// is the treatment, and it has to be: at a rate of nought the
|
||||
// two Prices are the same amount, and one key would have
|
||||
// Stripe hand back the same object for both — which the
|
||||
// register, where a Price id is unique, would refuse to
|
||||
// record twice. The metadata is folded in by IdempotencyKey
|
||||
// inside the client, so changing the format above can never
|
||||
// again refuse the call for a day. What stops a second Price
|
||||
// for one figure is the adoption step above, not this key —
|
||||
// Stripe forgets a key after twenty-four hours.
|
||||
idempotencyKey: "clupilot-price-{$price->id}-{$charged}"
|
||||
.($treatment->reverseCharge ? '-rc' : ''),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Legal;
|
||||
|
||||
use App\Console\Commands\PruneDormantAccounts;
|
||||
use App\Console\Commands\PruneUnverifiedAccounts;
|
||||
use App\Support\CompanyProfile;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use TCPDF;
|
||||
|
||||
/**
|
||||
* The processing agreement and its annex, as PDFs.
|
||||
*
|
||||
* TCPDF, because the invoices already render through it — a second PDF engine
|
||||
* for two documents a year would be a dependency nobody maintains. Its HTML
|
||||
* support is not a browser's, so the two Blade views are deliberately plain:
|
||||
* headings, paragraphs, lists, one table.
|
||||
*
|
||||
* ## The documents are generated, not uploaded
|
||||
*
|
||||
* They name the company from CompanyProfile and take their deadlines from the
|
||||
* commands that enforce them, so the agreement cannot drift from the invoices or
|
||||
* from what the software actually does. An operator can still upload a lawyer's
|
||||
* revision as a new version — the console does not care where a file came from.
|
||||
*/
|
||||
final class DpaRenderer
|
||||
{
|
||||
/**
|
||||
* The sub-processors this installation actually talks to.
|
||||
*
|
||||
* Here rather than in the template because it is a fact about the system,
|
||||
* not wording: hosting, payment and mail are the three places customer-
|
||||
* related data leaves this application, and a list that quietly omits one is
|
||||
* the single most common defect in a processing agreement.
|
||||
*
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
public static function subprocessors(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'Hetzner Online GmbH',
|
||||
'service' => 'Rechenzentrum, Server und Netzanbindung der Kundeninstanzen',
|
||||
'location' => 'Falkenstein und Nürnberg, Deutschland; Helsinki, Finnland',
|
||||
],
|
||||
[
|
||||
'name' => 'Stripe Payments Europe, Ltd.',
|
||||
'service' => 'Zahlungsabwicklung (Vertrags- und Zahlungsdaten des Kunden, keine Instanzinhalte)',
|
||||
'location' => 'Irland (EU)',
|
||||
],
|
||||
[
|
||||
'name' => 'thinkidoo e.U.',
|
||||
'service' => 'Mailserver für den Versand und Empfang der Betriebs- und Support-E-Mails',
|
||||
'location' => 'Österreich',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** The agreement itself. */
|
||||
public function agreement(string $version, ?Carbon $issuedOn = null): string
|
||||
{
|
||||
return $this->render('legal.dpa.agreement', $version, $issuedOn, [
|
||||
'subprocessors' => self::subprocessors(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Annex 1: the technical and organisational measures. */
|
||||
public function measures(string $version, ?Carbon $issuedOn = null): string
|
||||
{
|
||||
return $this->render('legal.dpa.measures', $version, $issuedOn, [
|
||||
// From the commands that enforce them, so the document cannot
|
||||
// promise one deadline while the sweep runs another.
|
||||
'unverifiedDays' => PruneUnverifiedAccounts::AFTER_DAYS,
|
||||
'warnDays' => PruneDormantAccounts::WARN_DAYS_BEFORE,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function render(string $view, string $version, ?Carbon $issuedOn, array $data): string
|
||||
{
|
||||
$issuedOn ??= now();
|
||||
|
||||
$html = View::make($view, array_merge($data, [
|
||||
'version' => $version,
|
||||
// R19: written on the wall clock, because a date on a contract is
|
||||
// read by a person and not by a server.
|
||||
'issuedOn' => $issuedOn->local()->isoFormat('LL'),
|
||||
]))->render();
|
||||
|
||||
$pdf = new TCPDF('P', 'mm', 'A4', true, 'UTF-8');
|
||||
$pdf->SetCreator('CluPilot');
|
||||
$pdf->SetAuthor((string) (CompanyProfile::get('name') ?? 'CluPilot'));
|
||||
$pdf->SetTitle(__('dpa.title').' '.$version);
|
||||
// No default header or footer bar: this is a contract, not a report, and
|
||||
// TCPDF's default header prints a black line and a title nobody set.
|
||||
$pdf->setPrintHeader(false);
|
||||
$pdf->setPrintFooter(true);
|
||||
$pdf->SetMargins(20, 18, 20);
|
||||
$pdf->SetAutoPageBreak(true, 20);
|
||||
// DejaVu, because the text is German: TCPDF's core fonts have no ß in
|
||||
// the encoding this uses and would print it as a blank.
|
||||
$pdf->SetFont('dejavusans', '', 9.5);
|
||||
$pdf->AddPage();
|
||||
$pdf->writeHTML($html, true, false, true, false, '');
|
||||
|
||||
return (string) $pdf->Output('', 'S');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Legal;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\DpaAcceptance;
|
||||
use App\Models\DpaVersion;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Which agreement is in force, and whether a customer has accepted it.
|
||||
*
|
||||
* One place, because the same questions are asked by the customer's own page, by
|
||||
* the console's register and by anything that later chases the ones outstanding
|
||||
* — and three implementations of "has this customer accepted?" is three chances
|
||||
* to answer differently a question that is evidence.
|
||||
*
|
||||
* ## Accepting an OLD version is not accepting
|
||||
*
|
||||
* A new version supersedes the last, so this asks about the version in force and
|
||||
* nothing else. The old acceptance is kept — it was true when it was made, and
|
||||
* the history is the point — but it stops satisfying the question.
|
||||
*/
|
||||
final class ProcessingAgreement
|
||||
{
|
||||
public function current(): ?DpaVersion
|
||||
{
|
||||
return DpaVersion::current();
|
||||
}
|
||||
|
||||
/** The acceptance of the version in force, if there is one. */
|
||||
public function acceptanceOf(?Customer $customer): ?DpaAcceptance
|
||||
{
|
||||
$current = $this->current();
|
||||
|
||||
if ($customer === null || $current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DpaAcceptance::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('dpa_version_id', $current->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function accepted(?Customer $customer): bool
|
||||
{
|
||||
return $this->acceptanceOf($customer) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an acceptance.
|
||||
*
|
||||
* firstOrCreate over the pair the unique index covers: pressing twice is not
|
||||
* two agreements, and a double click must not become a duplicate-key error
|
||||
* in front of somebody agreeing to a contract.
|
||||
*
|
||||
* The address is read here rather than passed in, so no caller can record an
|
||||
* acceptance as coming from somewhere it invented.
|
||||
*/
|
||||
public function accept(Customer $customer, ?string $ip = null): ?DpaAcceptance
|
||||
{
|
||||
$current = $this->current();
|
||||
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DpaAcceptance::query()->firstOrCreate(
|
||||
['customer_id' => $customer->id, 'dpa_version_id' => $current->id],
|
||||
[
|
||||
'user_id' => Auth::id(),
|
||||
'ip' => $ip ?? request()->ip(),
|
||||
'accepted_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,22 @@ class FakeMailbox implements InboundMailbox
|
|||
return $this->configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a test wants the check to answer, if not the obvious.
|
||||
*
|
||||
* @var array{ok: bool, message: string, unseen: int|null}|null
|
||||
*/
|
||||
public array|null $checkAnswer = null;
|
||||
|
||||
public function check(): array
|
||||
{
|
||||
return $this->checkAnswer ?? [
|
||||
'ok' => $this->configured,
|
||||
'message' => $this->configured ? '' : 'not configured',
|
||||
'unseen' => $this->configured ? count($this->mails) : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function fetch(int $limit = 50): array
|
||||
{
|
||||
return array_slice($this->mails, 0, $limit);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
|
@ -44,9 +45,20 @@ class ImapMailbox implements InboundMailbox
|
|||
* Configured means: somebody entered a host, a user and a password.
|
||||
*
|
||||
* The first two come from the console (ProvisioningSettings), the password
|
||||
* from the vault — read through config, which SecretVault overrides at
|
||||
* boot. No separate "enabled" switch to forget: a mailbox with credentials
|
||||
* IS the switch, and one without them cannot be polled anyway.
|
||||
* from the VAULT — read here, at the point of use. It used to read
|
||||
* config('services.inbound_mail.password'), which is only ever the .env
|
||||
* value: this project deliberately does not overlay stored secrets onto
|
||||
* config at boot (SecretVault's docblock, rule 3). So a password saved in
|
||||
* the console counted for nothing, isConfigured() stayed false, and the
|
||||
* inbox went on saying no mailbox was set up while the password sat right
|
||||
* there on the settings page. Reported exactly that way.
|
||||
*
|
||||
* SecretVault::get() falls back to the configured value when nothing is
|
||||
* stored, so an installation carrying INBOUND_MAIL_PASSWORD in .env keeps
|
||||
* working unchanged.
|
||||
*
|
||||
* No separate "enabled" switch to forget: a mailbox with credentials IS the
|
||||
* switch, and one without them cannot be polled anyway.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
|
|
@ -54,7 +66,63 @@ class ImapMailbox implements InboundMailbox
|
|||
|
||||
return filled($mailbox['host'])
|
||||
&& filled($mailbox['username'])
|
||||
&& filled(config('services.inbound_mail.password'));
|
||||
&& filled($this->password());
|
||||
}
|
||||
|
||||
/** The mailbox password, from the vault, falling back to .env. */
|
||||
private function password(): string
|
||||
{
|
||||
return (string) app(SecretVault::class)->get('inbound_mail.password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach the mailbox and count what is waiting.
|
||||
*
|
||||
* Deliberately the same four verbs the fetch uses — LOGIN, SELECT, SEARCH —
|
||||
* so a green check means the thing that actually runs would work. A check
|
||||
* that proves something easier than the real job is worse than none: it was
|
||||
* exactly that mistake (a test computing its expectation with the code under
|
||||
* test) the timezone rule was written about.
|
||||
*
|
||||
* @return array{ok: bool, message: string, unseen: int|null}
|
||||
*/
|
||||
public function check(): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
return ['ok' => false, 'message' => 'incomplete', 'unseen' => null];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->connect();
|
||||
$unseen = count($this->search());
|
||||
|
||||
return ['ok' => true, 'message' => '', 'unseen' => $unseen];
|
||||
} catch (Throwable $e) {
|
||||
// The message, not the trace, and never the command: LOGIN carries
|
||||
// the password and this string is shown on a page.
|
||||
return ['ok' => false, 'message' => $this->reason($e), 'unseen' => null];
|
||||
} finally {
|
||||
$this->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line an operator can act on, with nothing secret in it.
|
||||
*
|
||||
* IMAP servers answer a failed LOGIN with their own prose, and this class
|
||||
* puts the command in the exception message — which for LOGIN would be the
|
||||
* password. So the text is matched, never passed through.
|
||||
*/
|
||||
private function reason(Throwable $e): string
|
||||
{
|
||||
$text = strtolower($e->getMessage());
|
||||
|
||||
return match (true) {
|
||||
str_contains($text, 'could not reach') => 'unreachable',
|
||||
str_contains($text, 'authenticationfailed') || str_contains($text, 'login') => 'credentials',
|
||||
str_contains($text, 'nonexistent') || str_contains($text, 'select') => 'folder',
|
||||
default => 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<int, FetchedMail> */
|
||||
|
|
@ -147,7 +215,7 @@ class ImapMailbox implements InboundMailbox
|
|||
$this->command(sprintf(
|
||||
'LOGIN %s %s',
|
||||
$this->quote($mailbox['username']),
|
||||
$this->quote((string) config('services.inbound_mail.password')),
|
||||
$this->quote($this->password()),
|
||||
));
|
||||
|
||||
$this->command('SELECT '.$this->quote($mailbox['folder']));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* When the support mailbox was last reached, and what came back.
|
||||
*
|
||||
* A settings row rather than a table: there is exactly one mailbox and exactly
|
||||
* one last answer, and a table of one row is a table somebody has to prune.
|
||||
*
|
||||
* Written by both callers on purpose — the operator's test button and the
|
||||
* scheduled fetch. Otherwise "last checked" would mean "last time somebody
|
||||
* pressed the button", which is the least interesting of the two: a mailbox
|
||||
* that stopped answering at three in the morning is what an operator needs to
|
||||
* see, and nobody presses a button at three in the morning.
|
||||
*/
|
||||
class InboundMailStatus
|
||||
{
|
||||
private const KEY = 'inbound_mail.last_check';
|
||||
|
||||
/** @param int|null $unseen how many unread messages were waiting, where that was asked */
|
||||
public function record(bool $ok, string $message = '', ?int $unseen = null): void
|
||||
{
|
||||
Settings::set(self::KEY, [
|
||||
'at' => Carbon::now()->toIso8601String(),
|
||||
'ok' => $ok,
|
||||
// Never the password, never the whole exception: this ends up on a
|
||||
// page, and an IMAP error can quote the command it failed on.
|
||||
'message' => mb_substr($message, 0, 300),
|
||||
'unseen' => $unseen,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The last answer, or null if nobody has ever asked.
|
||||
*
|
||||
* @return array{at: Carbon, ok: bool, message: string, unseen: int|null}|null
|
||||
*/
|
||||
public function last(): ?array
|
||||
{
|
||||
$row = Settings::get(self::KEY);
|
||||
|
||||
if (! is_array($row) || ! isset($row['at'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
// Parsed back into a Carbon so the view can put it on the wall clock
|
||||
// (R19) rather than printing an ISO string at somebody.
|
||||
'at' => Carbon::parse((string) $row['at']),
|
||||
'ok' => (bool) ($row['ok'] ?? false),
|
||||
'message' => (string) ($row['message'] ?? ''),
|
||||
'unseen' => isset($row['unseen']) ? (int) $row['unseen'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,20 @@ interface InboundMailbox
|
|||
/** Is a mailbox configured at all? */
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* Reach the mailbox now and say what happened.
|
||||
*
|
||||
* For the button in the console: an operator who has just typed a host, a
|
||||
* user and a password should not have to wait for the next scheduled run to
|
||||
* find out whether any of it was right — and "the inbox is empty" is not an
|
||||
* answer to that question.
|
||||
*
|
||||
* Never throws. A failed check IS the answer.
|
||||
*
|
||||
* @return array{ok: bool, message: string, unseen: int|null}
|
||||
*/
|
||||
public function check(): array;
|
||||
|
||||
/**
|
||||
* Messages that have arrived, oldest first.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ use Illuminate\Support\Facades\Log;
|
|||
*/
|
||||
class IngestInboundMail
|
||||
{
|
||||
public function __construct(private readonly InboundMailbox $mailbox) {}
|
||||
public function __construct(
|
||||
private readonly InboundMailbox $mailbox,
|
||||
private readonly InboundMailStatus $status,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetch, store, and flag what was stored.
|
||||
|
|
@ -35,6 +38,18 @@ class IngestInboundMail
|
|||
return 0;
|
||||
}
|
||||
|
||||
// The scheduled run records its own outcome, so the "last checked" line
|
||||
// in the console means the last time the mailbox was actually reached —
|
||||
// not the last time somebody pressed a button. A mailbox that stopped
|
||||
// answering at three in the morning is the one worth seeing, and nobody
|
||||
// presses a button at three in the morning.
|
||||
$check = $this->mailbox->check();
|
||||
$this->status->record($check['ok'], $check['message'], $check['unseen']);
|
||||
|
||||
if (! $check['ok']) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$taken = 0;
|
||||
|
||||
foreach ($this->mailbox->fetch($limit) as $mail) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Mail\InvoiceMail;
|
||||
use App\Mail\MaintenanceAnnouncementMail;
|
||||
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;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\MaintenanceWindow;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Every mail this application sends, built from sample data so it can be looked
|
||||
* at without waiting for the event that would send it.
|
||||
*
|
||||
* There is no other way to see most of these. An invoice mail needs an invoice, a
|
||||
* maintenance announcement needs a window, and "register an account to check
|
||||
* whether the confirmation reads well on a phone" is not a workflow — which is
|
||||
* how a mail with a hard 600px table went out for months before anybody opened
|
||||
* one on a telephone.
|
||||
*
|
||||
* ## Nothing is written
|
||||
*
|
||||
* Every record here is built with `make()`, never `create()`: a preview must not
|
||||
* leave an invoice, an order or a maintenance window behind in the database. The
|
||||
* models exist for the length of one render.
|
||||
*
|
||||
* The one exception is by omission: where a real record already exists, it is
|
||||
* NOT used. Rendering somebody's actual invoice to check a layout puts their
|
||||
* figures in an operator's inbox for no reason.
|
||||
*/
|
||||
class MailPreviews
|
||||
{
|
||||
/**
|
||||
* The list, in the order somebody would read them: sign-up, purchase,
|
||||
* operation, money, security.
|
||||
*
|
||||
* @return array<string, string> key => label
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return [
|
||||
'verify-email' => 'E-Mail bestätigen (Registrierung)',
|
||||
'reset-password' => 'Passwort zurücksetzen',
|
||||
'order-confirmation' => 'Bestellbestätigung',
|
||||
'cloud-ready' => 'Cloud ist bereit (Zugangsdaten)',
|
||||
'maintenance-announcement' => 'Wartungsfenster angekündigt',
|
||||
'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',
|
||||
];
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* One mailable, filled with sample data.
|
||||
*
|
||||
* Returns null for a key nobody knows rather than throwing: this is reached
|
||||
* from a URL, and a URL is a string somebody can retype.
|
||||
*/
|
||||
public function make(string $key): ?Mailable
|
||||
{
|
||||
if (! $this->has($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$customer = $this->customer();
|
||||
$user = $this->user();
|
||||
|
||||
return match ($key) {
|
||||
'verify-email' => new VerifyEmailMail($user),
|
||||
'reset-password' => new ResetPasswordMail($user, 'https://example.test/reset/beispiel-token', 60),
|
||||
'order-confirmation' => new OrderConfirmationMail($this->order($customer), $customer->name),
|
||||
'cloud-ready' => $this->cloudReady($customer),
|
||||
'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer),
|
||||
'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',
|
||||
"Guten Tag Bea Berger,\n\nich habe mir Ihre Ausgangslage angesehen. Die Übernahme ist möglich.\n\nMit freundlichen Grüßen\nCluPilot",
|
||||
),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- The sample records. make(), never create(). ----
|
||||
|
||||
private function customer(): Customer
|
||||
{
|
||||
return Customer::make([
|
||||
'name' => 'Beispiel GmbH',
|
||||
'contact_name' => 'Bea Berger',
|
||||
'email' => 'bea@beispiel.test',
|
||||
'locale' => 'de',
|
||||
]);
|
||||
}
|
||||
|
||||
private function user(): User
|
||||
{
|
||||
// The verification mail signs its URL from the model key, so the sample
|
||||
// needs one — 0 is enough for a link nobody is meant to follow.
|
||||
return User::make(['name' => 'Bea Berger', 'email' => 'bea@beispiel.test'])
|
||||
->forceFill(['id' => 0, 'email_verified_at' => null]);
|
||||
}
|
||||
|
||||
private function order(Customer $customer): Order
|
||||
{
|
||||
return Order::make([
|
||||
'plan' => 'start',
|
||||
'amount_cents' => 4900,
|
||||
'currency' => 'EUR',
|
||||
'datacenter' => 'fsn',
|
||||
'status' => 'paid',
|
||||
])->forceFill(['id' => 0, 'created_at' => Carbon::now()]);
|
||||
}
|
||||
|
||||
private function invoice(Customer $customer): Invoice
|
||||
{
|
||||
// The document renders from its own snapshot, so a preview only needs a
|
||||
// convincing snapshot — not a series, not a number drawn from one.
|
||||
return Invoice::make([
|
||||
'number' => 'RE-2026-0042',
|
||||
'issued_on' => Carbon::now()->toDateString(),
|
||||
'due_on' => Carbon::now()->addDays(14)->toDateString(),
|
||||
'net_cents' => 4900,
|
||||
'tax_cents' => 980,
|
||||
'gross_cents' => 5880,
|
||||
'currency' => 'EUR',
|
||||
'snapshot' => ['meta' => ['number' => 'RE-2026-0042']],
|
||||
])->forceFill(['id' => 0, 'uuid' => '00000000-0000-0000-0000-000000000000']);
|
||||
}
|
||||
|
||||
private function window(): MaintenanceWindow
|
||||
{
|
||||
return MaintenanceWindow::make([
|
||||
'title' => 'Sicherheitsupdates Nextcloud',
|
||||
'starts_at' => Carbon::now()->addDays(3)->setTime(2, 0),
|
||||
'ends_at' => Carbon::now()->addDays(3)->setTime(4, 0),
|
||||
'state' => 'scheduled',
|
||||
])->forceFill(['id' => 0]);
|
||||
}
|
||||
|
||||
private function device(User $user): UserDevice
|
||||
{
|
||||
return UserDevice::make([
|
||||
'guard' => 'web',
|
||||
'name' => 'Firefox auf Linux',
|
||||
'last_ip' => '203.0.113.42',
|
||||
'last_seen_at' => Carbon::now(),
|
||||
])->forceFill(['id' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credentials mail is a notification rather than a Mailable.
|
||||
*
|
||||
* Built through its own toMail() so the preview shows the real thing, not a
|
||||
* second copy of the wording that would then drift.
|
||||
*/
|
||||
private function cloudReady(Customer $customer): Mailable
|
||||
{
|
||||
$mail = new class($customer) extends Mailable
|
||||
{
|
||||
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 $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject'));
|
||||
}
|
||||
|
||||
public function content(): \Illuminate\Mail\Mailables\Content
|
||||
{
|
||||
// The same keys App\Notifications\CloudReady hands the view. A
|
||||
// preview that invented its own would render a mail nobody ever
|
||||
// receives, and drift from the real one without saying so.
|
||||
return new \Illuminate\Mail\Mailables\Content(view: 'mail.cloud-ready', with: [
|
||||
'name' => $this->customer->name,
|
||||
'url' => 'https://beispiel.clupilot.cloud',
|
||||
'plan' => 'start',
|
||||
'storage' => __('provisioning.mail.value_storage', ['gb' => 100]),
|
||||
'location' => 'Falkenstein, Deutschland',
|
||||
'dashboardUrl' => route('dashboard'),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return $mail;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ class FakeStripeClient implements StripeClient
|
|||
/** @var array<int, array{name: string, metadata: array}> */
|
||||
public array $products = [];
|
||||
|
||||
/** @var array<int, array{product: string, amount: int, currency: string, interval: string, metadata: array}> */
|
||||
/** @var array<int, array{product: string, amount: int, currency: string, interval: string, metadata: array, created: int}> */
|
||||
public array $prices = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
|
|
@ -34,7 +34,25 @@ class FakeStripeClient implements StripeClient
|
|||
*/
|
||||
public array $activated = [];
|
||||
|
||||
/** Idempotency key → the id first returned for it. @var array<string, string> */
|
||||
/**
|
||||
* Every metadata write, in order, so a test can assert that an adopted
|
||||
* orphan was brought up to today's metadata instead of being replaced.
|
||||
*
|
||||
* @var array<int, array{price: string, metadata: array<string, string>}>
|
||||
*/
|
||||
public array $metadataUpdates = [];
|
||||
|
||||
/**
|
||||
* Idempotency key → what was first answered for it, and a fingerprint of
|
||||
* what it was first sent with.
|
||||
*
|
||||
* The fingerprint is the half that was missing. This ledger replayed the
|
||||
* first id for a repeated key without ever looking at what the second call
|
||||
* was asking for — so it was unrealistic in precisely the way that mattered,
|
||||
* and no test could see the 2026-07-29 blockade.
|
||||
*
|
||||
* @var array<string, array{id: string, fingerprint: string}>
|
||||
*/
|
||||
public array $keys = [];
|
||||
|
||||
/**
|
||||
|
|
@ -150,17 +168,25 @@ class FakeStripeClient implements StripeClient
|
|||
|
||||
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||
{
|
||||
$key = IdempotencyKey::forProduct($idempotencyKey, $name, $metadata);
|
||||
|
||||
// The same parameters forProduct() just fingerprinted, asked for
|
||||
// through the one method that knows what they are — a local literal
|
||||
// here could drift from what the key actually covers without a test
|
||||
// ever seeing it.
|
||||
$parameters = IdempotencyKey::productParameters($name, $metadata);
|
||||
|
||||
// Replays the first answer for a repeated key, as Stripe does.
|
||||
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
|
||||
return $this->keys[$idempotencyKey];
|
||||
$replayed = $this->replay($key, $parameters);
|
||||
|
||||
if ($replayed !== null) {
|
||||
return $replayed;
|
||||
}
|
||||
|
||||
$id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12);
|
||||
$this->products[$id] = ['name' => $name, 'metadata' => $metadata];
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$this->keys[$idempotencyKey] = $id;
|
||||
}
|
||||
$this->rememberKey($key, $id, $parameters);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
|
@ -173,8 +199,17 @@ class FakeStripeClient implements StripeClient
|
|||
array $metadata = [],
|
||||
?string $idempotencyKey = null,
|
||||
): string {
|
||||
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
|
||||
return $this->keys[$idempotencyKey];
|
||||
$key = IdempotencyKey::forPrice($idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata);
|
||||
|
||||
// Same reasoning as createProduct(): the parameters forPrice() just
|
||||
// fingerprinted, not a second literal that has to be kept in step by
|
||||
// hand.
|
||||
$parameters = IdempotencyKey::priceParameters($productId, $amountCents, $currency, $interval, $metadata);
|
||||
|
||||
$replayed = $this->replay($key, $parameters);
|
||||
|
||||
if ($replayed !== null) {
|
||||
return $replayed;
|
||||
}
|
||||
|
||||
$id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12);
|
||||
|
|
@ -184,11 +219,18 @@ class FakeStripeClient implements StripeClient
|
|||
'currency' => $currency,
|
||||
'interval' => $interval,
|
||||
'metadata' => $metadata,
|
||||
// Stripe stamps every object with its creation time and
|
||||
// AdoptStripePrice takes the OLDEST of several orphans. Counted
|
||||
// rather than clocked, because two Prices minted in the same second
|
||||
// would share a timestamp — but the two counters are independent:
|
||||
// plantPrice() stamps `created: 1` of its own accord, which ties with
|
||||
// a Price minted here while this list was still empty. Any test whose
|
||||
// outcome depends on which of two prices is older passes `created:`
|
||||
// itself, as the adoption tests do.
|
||||
'created' => count($this->prices) + 1,
|
||||
];
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$this->keys[$idempotencyKey] = $id;
|
||||
}
|
||||
$this->rememberKey($key, $id, $parameters);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
|
@ -211,6 +253,76 @@ class FakeStripeClient implements StripeClient
|
|||
$this->activated[] = $priceId;
|
||||
}
|
||||
|
||||
public function activePricesFor(string $productId): array
|
||||
{
|
||||
// No failIfAsked(): createPrice(), the call this one stands in front of,
|
||||
// does not fail either, and a test that scripts an outage for a refund
|
||||
// must not have its catalogue sync change behaviour underneath it.
|
||||
//
|
||||
// None of HttpStripeClient's charging-property filter either — the four
|
||||
// fields it names (interval_count, usage_type, transform_quantity,
|
||||
// billing_scheme). This fake only ever holds prices its own
|
||||
// createPrice()/plantPrice() put here, and neither takes an argument for
|
||||
// any of them: there is nothing non-standard a test could plant, so the
|
||||
// filter would have nothing to do. Not an oversight. What that filter
|
||||
// does is proven where it lives, over Http::fake — see
|
||||
// tests/Feature/Billing/StripeIdempotencyKeyTest.php.
|
||||
$found = [];
|
||||
|
||||
foreach ($this->prices as $id => $price) {
|
||||
if ($price['product'] !== $productId || in_array($id, $this->archived, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$found[] = [
|
||||
'id' => $id,
|
||||
'unit_amount' => $price['amount'],
|
||||
'currency' => strtoupper($price['currency']),
|
||||
'interval' => $price['interval'],
|
||||
'created' => $price['created'],
|
||||
'metadata' => $price['metadata'],
|
||||
];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
public function updatePriceMetadata(string $priceId, array $metadata): void
|
||||
{
|
||||
if (isset($this->prices[$priceId])) {
|
||||
$this->prices[$priceId]['metadata'] = $metadata;
|
||||
}
|
||||
|
||||
$this->metadataUpdates[] = ['price' => $priceId, 'metadata' => $metadata];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Price that exists at Stripe and in no table of ours — the orphan a run
|
||||
* leaves behind when it dies between Stripe's create and our insert.
|
||||
*
|
||||
* Here rather than in a test file because it is the state the whole adoption
|
||||
* step exists for, and every test that needs it would otherwise reach into
|
||||
* $prices and write the shape by hand.
|
||||
*/
|
||||
public function plantPrice(
|
||||
string $id,
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata = [],
|
||||
int $created = 1,
|
||||
): void {
|
||||
$this->prices[$id] = [
|
||||
'product' => $productId,
|
||||
'amount' => $amountCents,
|
||||
'currency' => $currency,
|
||||
'interval' => $interval,
|
||||
'metadata' => $metadata,
|
||||
'created' => $created,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSubscriptionPrice(
|
||||
string $subscriptionId,
|
||||
string $itemId,
|
||||
|
|
@ -308,9 +420,12 @@ class FakeStripeClient implements StripeClient
|
|||
throw new RuntimeException("Unknown cancellation timing: {$when}");
|
||||
}
|
||||
|
||||
// Replays the first answer for a repeated key, as Stripe does, so a retry
|
||||
// after a timeout records one order to stop rather than two.
|
||||
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
|
||||
// No fingerprint: for a subscription cancellation, a changed parameter
|
||||
// under a used key is exactly what must fail loudly. See IdempotencyKey.
|
||||
$parameters = ['subscription' => $subscriptionId, 'when' => $when];
|
||||
$replayed = $this->replay($idempotencyKey, $parameters);
|
||||
|
||||
if ($replayed !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -320,9 +435,7 @@ class FakeStripeClient implements StripeClient
|
|||
'key' => $idempotencyKey,
|
||||
];
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$this->keys[$idempotencyKey] = $subscriptionId;
|
||||
}
|
||||
$this->rememberKey($idempotencyKey, $subscriptionId, $parameters);
|
||||
}
|
||||
|
||||
public function refund(
|
||||
|
|
@ -332,10 +445,13 @@ class FakeStripeClient implements StripeClient
|
|||
): string {
|
||||
$this->failIfAsked();
|
||||
|
||||
// Replays the first answer for a repeated key, as Stripe does — which
|
||||
// is the whole point of sending one on a refund.
|
||||
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
|
||||
return $this->keys[$idempotencyKey];
|
||||
// No fingerprint: for a refund, a changed parameter under a used key is
|
||||
// exactly what must fail loudly. See IdempotencyKey.
|
||||
$parameters = ['payment' => $paymentReference, 'amount' => (string) $amountCents];
|
||||
$replayed = $this->replay($idempotencyKey, $parameters);
|
||||
|
||||
if ($replayed !== null) {
|
||||
return $replayed;
|
||||
}
|
||||
|
||||
$this->refunds[] = [
|
||||
|
|
@ -346,9 +462,7 @@ class FakeStripeClient implements StripeClient
|
|||
|
||||
$id = 're_'.substr(sha1($paymentReference.count($this->refunds)), 0, 12);
|
||||
|
||||
if ($idempotencyKey !== null) {
|
||||
$this->keys[$idempotencyKey] = $id;
|
||||
}
|
||||
$this->rememberKey($idempotencyKey, $id, $parameters);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
|
@ -382,4 +496,33 @@ class FakeStripeClient implements StripeClient
|
|||
throw new RuntimeException($this->failWith);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The id Stripe already answered for this key, or null when it is new.
|
||||
*
|
||||
* Throws on a key that comes back with different parameters, which is what
|
||||
* Stripe does — with this wording — and what makes a test able to see the
|
||||
* failure at all.
|
||||
*/
|
||||
private function replay(?string $key, array $parameters): ?string
|
||||
{
|
||||
if ($key === null || ! isset($this->keys[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->keys[$key]['fingerprint'] !== IdempotencyKey::fingerprint($parameters)) {
|
||||
throw new RuntimeException(
|
||||
'Keys for idempotent requests can only be used with the same parameters they were first used with.',
|
||||
);
|
||||
}
|
||||
|
||||
return $this->keys[$key]['id'];
|
||||
}
|
||||
|
||||
private function rememberKey(?string $key, string $id, array $parameters): void
|
||||
{
|
||||
if ($key !== null) {
|
||||
$this->keys[$key] = ['id' => $id, 'fingerprint' => IdempotencyKey::fingerprint($parameters)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class HttpStripeClient implements StripeClient
|
|||
|
||||
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
|
||||
{
|
||||
return (string) $this->request($idempotencyKey)
|
||||
return (string) $this->request(IdempotencyKey::forProduct($idempotencyKey, $name, $metadata))
|
||||
->asForm()
|
||||
->post($this->url('products'), array_filter([
|
||||
'name' => $name,
|
||||
|
|
@ -111,7 +111,9 @@ class HttpStripeClient implements StripeClient
|
|||
array $metadata = [],
|
||||
?string $idempotencyKey = null,
|
||||
): string {
|
||||
return (string) $this->request($idempotencyKey)
|
||||
return (string) $this->request(IdempotencyKey::forPrice(
|
||||
$idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata,
|
||||
))
|
||||
->asForm()
|
||||
->post($this->url('prices'), [
|
||||
'product' => $productId,
|
||||
|
|
@ -149,6 +151,91 @@ class HttpStripeClient implements StripeClient
|
|||
->throw();
|
||||
}
|
||||
|
||||
public function activePricesFor(string $productId): array
|
||||
{
|
||||
$prices = [];
|
||||
$after = null;
|
||||
|
||||
// Paged through to the end, for the same reason invoiceLines() is: Stripe
|
||||
// caps a page at a hundred, and a family Product collects a Price per
|
||||
// version, term, treatment and rate change. Stopping at the first page
|
||||
// would leave an orphan unfound and mint the duplicate anyway.
|
||||
do {
|
||||
$page = $this->request()
|
||||
->get($this->url('prices'), array_filter([
|
||||
'product' => $productId,
|
||||
'active' => 'true',
|
||||
'type' => 'recurring',
|
||||
'limit' => 100,
|
||||
'starting_after' => $after,
|
||||
], fn ($value) => $value !== null))
|
||||
->throw()
|
||||
->json();
|
||||
|
||||
$data = (array) ($page['data'] ?? []);
|
||||
|
||||
foreach ($data as $price) {
|
||||
$recurring = (array) ($price['recurring'] ?? []);
|
||||
|
||||
// Only prices we could have minted ourselves. createPrice() sends
|
||||
// product, unit_amount, currency, recurring[interval] and metadata
|
||||
// and nothing else, so Stripe defaults every other field that
|
||||
// decides what a Price CHARGES — and anything but those defaults
|
||||
// is a Price this platform did not write. Skipped here rather than
|
||||
// returned, because AdoptStripePrice compares amount, currency and
|
||||
// interval only and trusts that triple as the whole of what a
|
||||
// Price charges:
|
||||
//
|
||||
// - interval_count multiplies the period — 3 bills every three
|
||||
// months — while `interval` still reads 'month';
|
||||
// - usage_type other than 'licensed' bills metered usage;
|
||||
// - a transform_quantity divides the quantity before charging,
|
||||
// and modules are billed BY quantity — SyncStripeAddonItems
|
||||
// sums a pack into one item at quantity n — so divide_by 10
|
||||
// would charge a customer holding three for one;
|
||||
// - billing_scheme 'tiered' keeps the money in tiers, leaving
|
||||
// unit_amount null. Read as 0 below, so such a Price fails the
|
||||
// amount match only while the caller's own figure is not 0 —
|
||||
// and PlanPrices::ensure() has no zero guard, so that is not a
|
||||
// condition to rest on.
|
||||
//
|
||||
// An ABSENT key is Stripe's default and no reason to reject.
|
||||
// Read the other way round this filter would refuse every
|
||||
// legitimate price and turn recognition into a permanent no-op.
|
||||
if ((int) ($recurring['interval_count'] ?? 1) !== 1
|
||||
|| ($recurring['usage_type'] ?? 'licensed') !== 'licensed'
|
||||
|| (array) ($price['transform_quantity'] ?? []) !== []
|
||||
|| ($price['billing_scheme'] ?? 'per_unit') !== 'per_unit') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prices[] = [
|
||||
'id' => (string) ($price['id'] ?? ''),
|
||||
'unit_amount' => (int) ($price['unit_amount'] ?? 0),
|
||||
'currency' => strtoupper((string) ($price['currency'] ?? '')),
|
||||
'interval' => (string) ($recurring['interval'] ?? ''),
|
||||
'created' => (int) ($price['created'] ?? 0),
|
||||
'metadata' => array_map(
|
||||
fn ($value) => (string) $value,
|
||||
(array) ($price['metadata'] ?? []),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
$after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
|
||||
} while (($page['has_more'] ?? false) === true && $after !== null);
|
||||
|
||||
return $prices;
|
||||
}
|
||||
|
||||
public function updatePriceMetadata(string $priceId, array $metadata): void
|
||||
{
|
||||
$this->request()
|
||||
->asForm()
|
||||
->post($this->url('prices/'.$priceId), $this->flatten('metadata', $metadata))
|
||||
->throw();
|
||||
}
|
||||
|
||||
public function updateSubscriptionPrice(
|
||||
string $subscriptionId,
|
||||
string $itemId,
|
||||
|
|
@ -348,10 +435,13 @@ class HttpStripeClient implements StripeClient
|
|||
$request = Http::withToken($this->secret())->acceptJson()->timeout(20);
|
||||
|
||||
// Stripe replays the original response for a repeated key instead of
|
||||
// creating a second object. That covers the gap this cannot close on
|
||||
// its own: a crash between Stripe creating a Price and us storing its
|
||||
// id. Their keys expire after 24 hours, so it protects a retry, not a
|
||||
// sync re-run next week — for which the stored ids are the guard.
|
||||
// creating a second object — but ONLY for a call repeated exactly; a
|
||||
// changed parameter under a used key is HTTP 400 for twenty-four hours.
|
||||
// The catalogue calls therefore fold a fingerprint of their parameters
|
||||
// into the key (see IdempotencyKey); the money calls deliberately do
|
||||
// not. Their keys expire after 24 hours, so this protects a retry, not
|
||||
// a sync re-run next week — for which the stored ids and
|
||||
// AdoptStripePrice are the guard.
|
||||
return $idempotencyKey !== null
|
||||
? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
|
||||
: $request;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stripe;
|
||||
|
||||
/**
|
||||
* The Idempotency-Key header, formed so that it cannot lie.
|
||||
*
|
||||
* Stripe replays the first response for a repeated key — but only if the call
|
||||
* is repeated exactly. A key that comes back with so much as one added metadata
|
||||
* field is answered with HTTP 400 and stays poisoned for twenty-four hours.
|
||||
*
|
||||
* That happened on 2026-07-29: a run created a Price and died before the row was
|
||||
* written, 9da1358 then added the `tax_treatment` metadata field, and the next
|
||||
* sweep sent yesterday's key with today's parameters. The sweep was blocked —
|
||||
* and so was every module booking, because AddonPrices::ensure() runs inside one.
|
||||
*
|
||||
* The mistake was conceptual. A key does not state what an object IS; identity
|
||||
* is the Price's product, amount, currency and interval, and it is
|
||||
* AdoptStripePrice that recognises those. A key states "I have already sent
|
||||
* this call", so everything the call sends belongs in it — which is what
|
||||
* fingerprint() folds in.
|
||||
*
|
||||
* **Only for the catalogue calls.** createPrice() and createProduct(): a
|
||||
* duplicate there is a Stripe object nothing bills on, recoverable, while a
|
||||
* blockade reaches a paying customer. For refund(), cancelSubscription(),
|
||||
* addSubscriptionItem() and createCheckoutSession() it is the other way round —
|
||||
* a second object is the customer's money taken twice or a package billed
|
||||
* twice — so those keep their bare key and Stripe's refusal with it.
|
||||
*/
|
||||
final class IdempotencyKey
|
||||
{
|
||||
/** The key for a Price, carrying everything createPrice() puts on the wire. */
|
||||
public static function forPrice(
|
||||
?string $key,
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata,
|
||||
): ?string {
|
||||
return self::with($key, self::priceParameters($productId, $amountCents, $currency, $interval, $metadata));
|
||||
}
|
||||
|
||||
/** The key for a Product, for the same reason and against the same trap. */
|
||||
public static function forProduct(?string $key, string $name, array $metadata): ?string
|
||||
{
|
||||
return self::with($key, self::productParameters($name, $metadata));
|
||||
}
|
||||
|
||||
/**
|
||||
* What counts as "the same call" for a Price — public because
|
||||
* FakeStripeClient has to ask this exact question too, to decide whether a
|
||||
* repeated key is a retry or a changed call. Built inline a second time in
|
||||
* the fake, it would only ever match this by coincidence, and a later
|
||||
* change to what a Price call fingerprints could drift from what the fake
|
||||
* detects without a single test noticing — which is exactly the gap this
|
||||
* task exists to close.
|
||||
*/
|
||||
public static function priceParameters(
|
||||
string $productId,
|
||||
int $amountCents,
|
||||
string $currency,
|
||||
string $interval,
|
||||
array $metadata,
|
||||
): array {
|
||||
return [
|
||||
'product' => $productId,
|
||||
'unit_amount' => $amountCents,
|
||||
'currency' => strtolower($currency),
|
||||
'interval' => $interval,
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/** What counts as "the same call" for a Product — same reason as priceParameters(). */
|
||||
public static function productParameters(string $name, array $metadata): array
|
||||
{
|
||||
return ['name' => $name, 'metadata' => $metadata];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eight hex characters over the parameters, canonically ordered.
|
||||
*
|
||||
* Ordered, because PHP keeps insertion order and two callers writing the
|
||||
* same metadata in a different order would otherwise send two keys for one
|
||||
* call — which would mint two Prices and be a worse bug than the one this
|
||||
* closes.
|
||||
*/
|
||||
public static function fingerprint(array $parameters): string
|
||||
{
|
||||
return substr(sha1(self::canonical($parameters)), 0, 8);
|
||||
}
|
||||
|
||||
private static function with(?string $key, array $parameters): ?string
|
||||
{
|
||||
// Null stays null: a caller who sends no key wants no replay, and
|
||||
// inventing one here would change what the call means.
|
||||
return $key === null ? null : $key.'-'.self::fingerprint($parameters);
|
||||
}
|
||||
|
||||
private static function canonical(array $parameters): string
|
||||
{
|
||||
ksort($parameters);
|
||||
|
||||
foreach ($parameters as $name => $value) {
|
||||
$parameters[$name] = is_array($value) ? self::canonical($value) : (string) $value;
|
||||
}
|
||||
|
||||
return json_encode($parameters, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,54 @@ interface StripeClient
|
|||
?string $idempotencyKey = null,
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Every active recurring Price of a Product, as Stripe holds them.
|
||||
*
|
||||
* The half of the catalogue mirror that was missing: nothing here ever asked
|
||||
* Stripe what it already had. A run that created a Price and died before the
|
||||
* row was written left an orphan our table never learned about, and once the
|
||||
* idempotency key expired the next run made a SECOND live Price for the same
|
||||
* money — the exact duplicate the key exists to prevent. See
|
||||
* App\Services\Billing\AdoptStripePrice, which is the only caller.
|
||||
*
|
||||
* Active ones only, because the question being asked is "is there something
|
||||
* here I can sell on?". `currency` comes back UPPER CASE, the way our own
|
||||
* tables hold it.
|
||||
*
|
||||
* FOUR properties are checked, and they are named because the list is what
|
||||
* the contract is: `recurring.interval_count` is 1, `recurring.usage_type`
|
||||
* is `licensed`, there is no `transform_quantity`, and `billing_scheme` is
|
||||
* `per_unit`. Those are Stripe's own defaults, and createPrice() sets none of
|
||||
* the four — so anything else is a Price this platform did not write, and is
|
||||
* filtered out here rather than returned.
|
||||
*
|
||||
* The reason it is filtered at all: AdoptStripePrice compares the amount,
|
||||
* currency and interval of the shape below and trusts that triple as the
|
||||
* WHOLE of what a Price charges. A Price billed every three months, on
|
||||
* metered usage, dividing the quantity before charging, or pricing in tiers
|
||||
* would otherwise pass that triple on `interval => 'month'` alone and be
|
||||
* adopted as if it charged our figure per unit per month — which is adoption
|
||||
* moving money, the one thing nothing here may do.
|
||||
*
|
||||
* Not a list of everything Stripe can put on a Price: it is the list of
|
||||
* fields known to change what one CHARGES. A fifth would have to be added
|
||||
* here, at the boundary, before the shape below is handed on.
|
||||
*
|
||||
* @return array<int, array{id: string, unit_amount: int, currency: string, interval: string, created: int, metadata: array<string, string>}>
|
||||
*/
|
||||
public function activePricesFor(string $productId): array;
|
||||
|
||||
/**
|
||||
* Write metadata onto a Price that already exists.
|
||||
*
|
||||
* Metadata is one of the few fields a Stripe Price lets you change — the
|
||||
* same property activatePrice() rests on, and the reason the metadata format
|
||||
* is NOT part of a Price's identity. An adopted orphan is brought up to
|
||||
* today's metadata rather than replaced, which is exactly what was done by
|
||||
* hand after 2026-07-29 and keeps Stripe's own dashboard readable.
|
||||
*/
|
||||
public function updatePriceMetadata(string $priceId, array $metadata): void;
|
||||
|
||||
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
|
||||
public function archivePrice(string $priceId): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,14 @@ final class Navigation
|
|||
]],
|
||||
['label' => __('admin.nav_group.system'), 'items' => [
|
||||
['admin.mail', 'mail', 'mail', 'mail.manage'],
|
||||
// Looking at a mail before a customer does. Beside the mailboxes
|
||||
// it is sent from, not under Betrieb: both are about how mail
|
||||
// leaves this installation.
|
||||
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
|
||||
// The processing agreement. Under System with the other things
|
||||
// that are true of the whole installation rather than of one
|
||||
// customer.
|
||||
['admin.dpa', 'file-text', 'dpa', 'dpa.manage'],
|
||||
// System, not Betrieb: the tunnel is how the console reaches
|
||||
// the estate at all. Nothing is provisioned, monitored or
|
||||
// updated through anything else, so it belongs beside the other
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* One row per Stripe Price on the module side too.
|
||||
*
|
||||
* `stripe_plan_prices.stripe_price_id` has been unique since
|
||||
* 2026_07_30_110000; the module register was never given the same guard, so two
|
||||
* rows could claim one Price — and archiving the one would have withdrawn the
|
||||
* Price the other was still selling. It is also the net under the adoption step
|
||||
* (App\Services\Billing\AdoptStripePrice): at a VAT rate of nought both
|
||||
* treatments charge the same amount, and the check that refuses an
|
||||
* already-claimed Price is then the only thing keeping them apart.
|
||||
*
|
||||
* Duplicates are DELETED rather than archived. An archived row goes on claiming
|
||||
* the id, and a unique index knows nothing about `archived_at`. Deleting is safe
|
||||
* because the row rebuilds itself — but not through adoption: the survivor keeps
|
||||
* the shared Price, so AdoptStripePrice's `claimed` callback reports it taken no
|
||||
* matter which row asks, and the deleted row's own tuple — necessarily a
|
||||
* DIFFERENT one, since the composite unique on (addon_key, reverse_charge,
|
||||
* amount_cents, currency, interval) from 2026_07_31_200000 forbids two rows
|
||||
* sharing a tuple — mints a fresh Price of its own instead, under its own
|
||||
* tuple-specific idempotency key (AddonPrices::ensure() falling through to
|
||||
* createPrice()). `subscription_addons` holds the Stripe id as text, not as a
|
||||
* foreign key, so no booking loses its price either way.
|
||||
*
|
||||
* MariaDB does not run DDL in a transaction, so the deletes below are already
|
||||
* committed by the time `Schema::table()` adds the index — a row inserted between
|
||||
* the two on a Price id that is still shared aborts the migration with the deletes
|
||||
* standing. Re-running is safe (the dedupe is idempotent and the index is simply
|
||||
* not there yet), but the sweep and anything else calling
|
||||
* AddonPrices::ensure() — a customer's module booking included — should be
|
||||
* quiesced for the one real run this gets.
|
||||
*
|
||||
* The `Log::warning` below is the only record this ever gets: `down()` restores
|
||||
* the index, not the rows it deleted. It names the shared Price, the row kept,
|
||||
* and every row thrown away, so an operator reading the log after the one real
|
||||
* run this migration gets can check the survivor's `amount_cents` against what
|
||||
* Stripe actually charges for that Price — the lowest id is written first, not
|
||||
* necessarily right, and nothing else will say what was discarded.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Written with groupBy/having and a delete by id rather than a joined
|
||||
// delete: production is MariaDB and the suite runs on SQLite.
|
||||
$shared = DB::table('stripe_addon_prices')
|
||||
->select('stripe_price_id')
|
||||
->groupBy('stripe_price_id')
|
||||
->havingRaw('count(*) > 1')
|
||||
->pluck('stripe_price_id');
|
||||
|
||||
foreach ($shared as $priceId) {
|
||||
$rows = DB::table('stripe_addon_prices')->where('stripe_price_id', $priceId)->get();
|
||||
|
||||
// The lowest id stays — the row that was written first, which is the
|
||||
// one anything already billing is likeliest to have been reading.
|
||||
$keep = (int) $rows->min('id');
|
||||
$kept = $rows->first(fn ($row) => (int) $row->id === $keep);
|
||||
$discarded = $rows->reject(fn ($row) => (int) $row->id === $keep);
|
||||
|
||||
// The only forensic trace this delete ever leaves — see the class
|
||||
// docblock for why that has to be enough.
|
||||
Log::warning('stripe: kept one row of several claiming one price, deleted the rest', [
|
||||
'stripe_price_id' => $priceId,
|
||||
'kept' => [
|
||||
'id' => $kept->id,
|
||||
'addon_key' => $kept->addon_key,
|
||||
'reverse_charge' => (bool) $kept->reverse_charge,
|
||||
'amount_cents' => $kept->amount_cents,
|
||||
'currency' => $kept->currency,
|
||||
'interval' => $kept->interval,
|
||||
],
|
||||
'deleted' => $discarded->map(fn ($row) => [
|
||||
'id' => $row->id,
|
||||
'addon_key' => $row->addon_key,
|
||||
'reverse_charge' => (bool) $row->reverse_charge,
|
||||
'amount_cents' => $row->amount_cents,
|
||||
'currency' => $row->currency,
|
||||
'interval' => $row->interval,
|
||||
])->values()->all(),
|
||||
]);
|
||||
|
||||
DB::table('stripe_addon_prices')
|
||||
->where('stripe_price_id', $priceId)
|
||||
->where('id', '!=', $keep)
|
||||
->delete();
|
||||
}
|
||||
|
||||
Schema::table('stripe_addon_prices', function (Blueprint $table) {
|
||||
$table->unique('stripe_price_id', 'stripe_addon_prices_price_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('stripe_addon_prices', function (Blueprint $table) {
|
||||
$table->dropUnique('stripe_addon_prices_price_unique');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* How many months a customer gets for nothing by paying for a year.
|
||||
*
|
||||
* The yearly price was already there — one `plan_prices` row per term, each with
|
||||
* its own Stripe Price — but only as a total somebody typed. So nothing in the
|
||||
* system knew WHY 588 was the yearly figure for a package costing 49 a month,
|
||||
* and no page could say "two months free" without a second person working it out
|
||||
* by hand and writing it down somewhere it would then drift.
|
||||
*
|
||||
* The figure lives on the VERSION, beside the capabilities publication freezes,
|
||||
* because that is what it is: part of the terms a customer bought. The yearly
|
||||
* amount stays in `plan_prices` — Stripe charges an amount, not a discount — and
|
||||
* the console derives one from the other so the two cannot disagree.
|
||||
*
|
||||
* ## The backfill
|
||||
*
|
||||
* Read out of the prices that exist rather than defaulted to zero: an
|
||||
* installation already selling twelve-for-ten must not lose that on a deploy.
|
||||
* Only an exact division counts. A yearly price of 580 against a monthly of 49
|
||||
* is not "eleven and a bit months free", it is a figure somebody negotiated, and
|
||||
* 0 leaves it alone — the amount charged is untouched either way, only the
|
||||
* sentence beside it goes unsaid.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plan_versions', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('free_months')->default(0)->after('seats');
|
||||
});
|
||||
|
||||
$monthly = DB::table('plan_prices')
|
||||
->where('term', Subscription::TERM_MONTHLY)
|
||||
->pluck('amount_cents', 'plan_version_id');
|
||||
|
||||
foreach (DB::table('plan_prices')->where('term', Subscription::TERM_YEARLY)->get() as $yearly) {
|
||||
$perMonth = (int) ($monthly[$yearly->plan_version_id] ?? 0);
|
||||
|
||||
if ($perMonth <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Modulo, not a division compared against its own cast: PHP's `/`
|
||||
// returns an INT when two ints divide exactly, so `12 === 12.0` is
|
||||
// false and a check written that way rejects precisely the case it
|
||||
// was meant to accept. This one asks the only question there is —
|
||||
// does the monthly price go into the yearly one a whole number of
|
||||
// times.
|
||||
if ((int) $yearly->amount_cents % $perMonth !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$paidMonths = intdiv((int) $yearly->amount_cents, $perMonth);
|
||||
|
||||
// Inside the year. Anything else was priced by hand and is left to
|
||||
// speak for itself.
|
||||
if ($paidMonths < 1 || $paidMonths > 12) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('plan_versions')
|
||||
->where('id', $yearly->plan_version_id)
|
||||
->update(['free_months' => 12 - $paidMonths]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plan_versions', function (Blueprint $table) {
|
||||
$table->dropColumn('free_months');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The billing address, as fields rather than as a paragraph.
|
||||
*
|
||||
* It was one textarea, so what landed in it was whatever somebody typed: a
|
||||
* postcode on the street line, a city with no postcode at all, an address with
|
||||
* the country missing. An invoice has to name a recipient precisely enough to be
|
||||
* a document, and a free-text block cannot be checked, cannot be sorted, and
|
||||
* cannot be handed to anything that expects a country.
|
||||
*
|
||||
* ## The old column stays, and stays authoritative for what is in it
|
||||
*
|
||||
* `billing_address` is not dropped and not parsed. Guessing which line of an
|
||||
* existing block is the street would put a postcode where a street belongs on a
|
||||
* document nobody can correct afterwards — the same reason IssueInvoice carries
|
||||
* it as lines today. It becomes the COMPOSED form instead: whenever the fields
|
||||
* are saved, the block is rewritten from them, so invoices, the console's
|
||||
* customer page and everything else that reads it keep working untouched. A
|
||||
* record nobody has re-saved keeps the block it always had.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->string('billing_street')->nullable()->after('billing_address');
|
||||
$table->string('billing_postcode', 32)->nullable()->after('billing_street');
|
||||
$table->string('billing_city')->nullable()->after('billing_postcode');
|
||||
$table->string('billing_country')->nullable()->after('billing_city');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('customers', function (Blueprint $table) {
|
||||
$table->dropColumn(['billing_street', 'billing_postcode', 'billing_city', 'billing_country']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Why a customer left.
|
||||
*
|
||||
* The cancellation recorded WHEN and nothing else, so the one thing worth
|
||||
* knowing about a departure — whether it was the price, a missing feature, or a
|
||||
* competitor — existed only in whatever the customer happened to write to
|
||||
* support afterwards, which most do not.
|
||||
*
|
||||
* On the contract rather than on the instance: a contract is what is cancelled,
|
||||
* and it is where the withdrawal is already recorded (withdrawal_channel and the
|
||||
* rest). The reason is a fixed key, the note is what they typed.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('subscriptions', function (Blueprint $table) {
|
||||
$table->string('cancel_reason', 32)->nullable()->after('cancel_requested_at');
|
||||
$table->text('cancel_reason_note')->nullable()->after('cancel_reason');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('subscriptions', function (Blueprint $table) {
|
||||
$table->dropColumn(['cancel_reason', 'cancel_reason_note']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The data-processing agreement, and the proof that a customer has it.
|
||||
*
|
||||
* Art. 28(3) GDPR requires one wherever personal data is processed on somebody
|
||||
* else's behalf, which is the whole of what this product does. "In writing"
|
||||
* there explicitly includes electronic form, so a document the customer can read
|
||||
* and a recorded acceptance is enough — a signature on paper is not required.
|
||||
*
|
||||
* Two tables, because they answer two different questions:
|
||||
*
|
||||
* - `dpa_versions` is the DOCUMENT. The operator uploads it (the text is a
|
||||
* lawyer's, not this application's), gives it a version, and publishes. The
|
||||
* technical and organisational measures are a second file rather than a
|
||||
* second concept: they are an annex to the agreement and are versioned with
|
||||
* it, so "which TOMs applied when they accepted" has one answer.
|
||||
* - `dpa_acceptances` is the EVIDENCE: which customer accepted which version,
|
||||
* when, from where, and which login did it. One row per acceptance and never
|
||||
* an update — a new version is a new row, and the history is the point.
|
||||
*
|
||||
* The file lives on the private disk. An agreement is not a public asset, and a
|
||||
* guessable URL to it would be a list of who our customers are.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('dpa_versions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
// What the operator calls it — "1.0", "2026-08", whatever the
|
||||
// document itself says. Unique, because two documents with one name
|
||||
// makes every acceptance ambiguous.
|
||||
$table->string('version', 64)->unique();
|
||||
$table->string('agreement_path');
|
||||
$table->string('measures_path')->nullable();
|
||||
$table->text('note')->nullable();
|
||||
// Null while it is being prepared. Only a published version is
|
||||
// shown to anybody, and only the newest published one is current.
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('operators')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('published_at');
|
||||
});
|
||||
|
||||
Schema::create('dpa_acceptances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
|
||||
$table->foreignId('dpa_version_id')->constrained('dpa_versions')->cascadeOnDelete();
|
||||
// The login that pressed it, kept as evidence even when that user is
|
||||
// later removed — the acceptance was still made.
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('ip', 45)->nullable();
|
||||
$table->timestamp('accepted_at');
|
||||
$table->timestamps();
|
||||
|
||||
// One acceptance per customer per version. Pressing twice is not two
|
||||
// agreements, and the unique index is what makes that true rather
|
||||
// than a check somebody can forget.
|
||||
$table->unique(['customer_id', 'dpa_version_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('dpa_acceptances');
|
||||
Schema::dropIfExists('dpa_versions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
* Publishing the processing agreement is a legal act, not an everyday one.
|
||||
*
|
||||
* Its own capability rather than settings.manage: whoever keeps the platform
|
||||
* running does not thereby decide what every customer is asked to agree to.
|
||||
*
|
||||
* Guard `operator`, not `web`. RBAC moved to the operator guard on 2026-07-29
|
||||
* (see that migration) — every capability written under `web` after it lands in
|
||||
* a guard nothing authenticates against, and the console answers 403 for a role
|
||||
* that visibly has the permission. Which is exactly what happened here first.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$permission = Permission::findOrCreate('dpa.manage', 'operator');
|
||||
|
||||
foreach (['Owner', 'Admin'] as $role) {
|
||||
Role::findOrCreate($role, 'operator')->givePermissionTo($permission);
|
||||
}
|
||||
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
Permission::where('name', 'dpa.manage')->where('guard_name', 'operator')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
}
|
||||
};
|
||||
|
|
@ -172,8 +172,11 @@ Neuer Schritt zwischen `SecureHostFirewall` und `CompleteHostOnboarding`:
|
|||
- `VerifyVmTemplate` prüft nur **Existenz**, nicht `template: 1`.
|
||||
- `applyFirewall()` setzt **nicht** `firewall=1` an `net0` der VM — ohne das greifen
|
||||
die Gastregeln trotz Datacenter-Firewall nicht. Prüfen, ob die Vorlage es mitbringt.
|
||||
- `stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon):
|
||||
zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.
|
||||
- ~~`stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon):
|
||||
zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.~~
|
||||
**Erledigt** — eindeutig seit `2026_07_31_210000_one_row_per_stripe_price`; zugleich
|
||||
das Netz unter dem Wiedererkennungsschritt, siehe
|
||||
`docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md`.
|
||||
- `clupilot:end-due-services` schließt den **Vertrag** nicht — ein geschenkter Vertrag
|
||||
bleibt nach Ende der Instanz für immer `active` und zählt in Umsatz/Dashboard mit.
|
||||
- Eine Abbuchung, die **vor** dem Widerruf entstand und **danach** bezahlt wird,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,359 @@
|
|||
# Spec — Bestehende Stripe-Preise wiedererkennen, statt sie zu verdoppeln
|
||||
|
||||
**Datum:** 2026-07-30
|
||||
**Status:** entworfen, noch nicht umgesetzt
|
||||
**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` Block D
|
||||
(`stripe_addon_prices.stripe_price_id` ist nicht eindeutig)
|
||||
|
||||
---
|
||||
|
||||
## 1. Der Vorfall
|
||||
|
||||
Ein Lauf am **2026-07-29 23:11** legte bei Stripe
|
||||
`price_1TygdEC7u8NpJ8pOt3nsoyYw` an (`priority_support`, 3480 EUR, monatlich) und
|
||||
brach ab, **bevor** die Zeile in `stripe_addon_prices` geschrieben war. Commit
|
||||
`9da1358` fügte danach das Metadatenfeld `tax_treatment` hinzu. Der nächste
|
||||
Abgleich schickte **denselben** Idempotenz-Schlüssel mit den **neuen** Metadaten
|
||||
und bekam von Stripe:
|
||||
|
||||
> HTTP 400 — Keys for idempotent requests can only be used with the same
|
||||
> parameters they were first used with.
|
||||
|
||||
Der Abgleich war damit hart blockiert und wäre es bis zum Ablauf des Schlüssels
|
||||
(24 h) geblieben. Von Hand bereinigt: Zeile für den vorhandenen Preis
|
||||
nachgetragen, Metadaten bei Stripe angeglichen. Die Ursache stand offen.
|
||||
|
||||
## 2. Ausgangsbefund — gemessen, nicht gelesen
|
||||
|
||||
| Befund | Beleg |
|
||||
|---|---|
|
||||
| Der Modul-Schlüssel deckt Betrag, Währung, Intervall, Modul und Behandlung ab — **keine** Metadaten | `app/Services/Billing/AddonPrices.php:152` |
|
||||
| Der Paket-Schlüssel ebenso | `app/Services/Billing/PlanPrices.php:158` |
|
||||
| Beide senden Metadaten mit, die **nicht** im Schlüssel stehen — u. a. das neue `tax_treatment` | `AddonPrices.php:133-141`, `PlanPrices.php:141-151` |
|
||||
| `createProduct` hat dieselbe Falle latent: Metadaten mit, Schlüssel ohne | `AddonPrices.php:269-273`, `SyncStripeCatalogue.php:104-111`, `HttpStripeClient.php:87-97` |
|
||||
| Kein Aufruf fragt Stripe nach **vorhandenen** Preisen. Der Client hat gar keine Methode dafür | `app/Services/Stripe/StripeClient.php` — `createPrice`, `archivePrice`, `activatePrice`, kein Auflisten |
|
||||
| `ensure()` läuft **nicht nur** im Abgleich, sondern in der **Kundenbuchung** | `BookAddon.php:259` und `:360` → `SyncStripeAddonItems.php:183` und `:291` → `AddonPrices::ensure()` |
|
||||
| Der Fake spielt einen bekannten Schlüssel zurück, **ohne die Parameter zu vergleichen** | `FakeStripeClient.php:168-193`, Ledger `$keys` (`:38`) |
|
||||
| `stripe_plan_prices.stripe_price_id` ist eindeutig | Migration `2026_07_30_110000:56` |
|
||||
| `stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig; eindeutig ist nur das Tupel | Migration `2026_07_31_200000:59-77` |
|
||||
| Der Client blättert schon woanders durch Stripe-Listen | `HttpStripeClient::invoiceLines()`, `:312-336` |
|
||||
| Produktion `mariadb`, Tests `sqlite :memory:` | `.env:24`, `phpunit.xml:42-43` |
|
||||
|
||||
**Der 400 ist damit kein Betriebsfehler, sondern ein Kundenfehler.** Ein
|
||||
Kunde, der ein Modul bucht, während ein Waisen-Preis mit altem Metadatenformat
|
||||
bei Stripe liegt, bekommt 24 Stunden lang keine Buchung zustande.
|
||||
|
||||
## 3. Zwei getrennte Mängel
|
||||
|
||||
1. **Waise ohne Wiedererkennung.** Ein Abgleich, der zwischen Stripes Anlage und
|
||||
unserem Schreiben abbricht, hinterlässt einen Preis bei Stripe, den unsere
|
||||
Tabelle nie erfährt. Nach Ablauf der 24 Stunden legt der nächste Lauf einen
|
||||
**zweiten aktiven** Preis über dieselbe Summe an — genau das, was der
|
||||
Schlüssel verhindern soll.
|
||||
2. **Metadaten im Aufruf, nicht im Schlüssel.** Jede künftige Änderung an den
|
||||
Metadaten von `createPrice` blockiert den Abgleich für 24 Stunden auf
|
||||
dieselbe Weise.
|
||||
|
||||
## 4. Gehört das Metadatenformat zur Identität? — Nein
|
||||
|
||||
**Identität** eines Preises ist, was Stripe unveränderlich macht: Produkt,
|
||||
Betrag, Währung, Intervall — und, weil bei einem Steuersatz von 0 % beide
|
||||
Behandlungen denselben Betrag haben, welche der beiden es ist. Metadaten sind
|
||||
bei Stripe **nachträglich änderbar**. Deshalb wird ein wiedererkannter Preis mit
|
||||
korrigierten Metadaten **übernommen**, nicht durch einen neuen ersetzt.
|
||||
|
||||
Der Idempotenz-Schlüssel behauptet aber gar keine Identität. Er behauptet
|
||||
„**diesen Aufruf habe ich schon abgeschickt**". Weil Stripe das wörtlich prüft,
|
||||
müssen **alle gesendeten Parameter** hinein — sonst ist der Schlüssel eine
|
||||
Lüge, die mit 400 endet.
|
||||
|
||||
Der Kommentar im Code verwechselt beides („Keyed on what the Price IS",
|
||||
`AddonPrices.php:142-151`). Das ist die eigentliche Ursache und wird
|
||||
mitkorrigiert: die Identität wandert in den Wiedererkennungsschritt, der
|
||||
Schlüssel wird auf seine tatsächliche Aussage zurückgeführt.
|
||||
|
||||
## 5. Regeln des Auftraggebers, die dieser Entwurf nicht anfassen darf
|
||||
|
||||
Beide wurden im Gespräch am 2026-07-30 gesetzt und gelten als Prüfstein:
|
||||
|
||||
1. **Bestehende Verträge behalten ihre alten Preise.** Neue Preise gelten nur
|
||||
für Kunden, die neu abschließen. Daraus folgt die Freigabe zum Archivieren:
|
||||
ein archivierter Stripe-Preis bleibt für laufende Abos in Kraft, er wird nur
|
||||
nicht mehr **verkauft** — worauf `archiveSuperseded()` schon heute beruht.
|
||||
2. **Jede Buchung friert ihren Preis ein.** Ein Paket und zwei Module werden
|
||||
immer gleich verrechnet; ein später gebuchtes Modul kommt zum heutigen Preis;
|
||||
eine Stornierung zieht genau den damaligen Betrag ab und wird danach nicht
|
||||
mehr verrechnet.
|
||||
|
||||
Beides bleibt strukturell gewahrt, weil die Übernahme **ausschließlich** Preise
|
||||
mit **identischem** Betrag, Währung und Intervall betrifft. Sie kann niemandes
|
||||
Verrechnung verschieben. §11 schreibt es als Test fest.
|
||||
|
||||
## 6. Bauteil 1 — `App\Services\Billing\AdoptStripePrice`
|
||||
|
||||
Der Wiedererkennungsschritt, **einmal** für beide Seiten (Modul und Paket), weil
|
||||
zwei Fassungen derselben Regel der Anfang ihres Auseinanderlaufens sind.
|
||||
|
||||
**Stellung im Ablauf:** nach dem Blick in unsere eigene Tabelle, vor
|
||||
`createPrice`. Also genau dann ein zusätzliches GET, wenn ohnehin ein POST
|
||||
fällig gewesen wäre — nie im Normalfall.
|
||||
|
||||
```
|
||||
ensure()
|
||||
├─ Zeile in unserer Tabelle? → ja: wie heute (ggf. reaktivieren)
|
||||
├─ AdoptStripePrice: Kandidat bei Stripe? → ja: übernehmen
|
||||
└─ createPrice(…) → nein: wie heute anlegen
|
||||
```
|
||||
|
||||
**Ein Kandidat wird übernommen, wenn er alle fünf Bedingungen erfüllt:**
|
||||
|
||||
1. bei Stripe **aktiv** und an **unserem** Produkt hängend
|
||||
(`active=true`, `product=…`, `type=recurring`);
|
||||
2. **exakt** gleich in Betrag, Währung (klein/groß normalisiert) und Intervall;
|
||||
3. **widerspricht keinem** unserer Metadatenschlüssel, den er trägt;
|
||||
4. **bestätigt mindestens einen** tragenden Schlüssel — `addon` auf der
|
||||
Modulseite, `plan_price_id` auf der Paketseite. Ein von Hand im Dashboard
|
||||
angelegter Preis wird damit nie übernommen;
|
||||
5. ist **von keiner Zeile beansprucht** (Prüfung gegen die eigene Tabelle,
|
||||
archivierte Zeilen eingeschlossen).
|
||||
|
||||
**Danach:** Metadaten bei Stripe angleichen (genau das, was von Hand getan
|
||||
wurde), Zeile schreiben, `archiveSuperseded()` wie im Anlege-Pfad, Preis-ID
|
||||
zurückgeben.
|
||||
|
||||
**Mehrere Kandidaten:** der **älteste** (`created` aufsteigend) wird übernommen —
|
||||
er ist am ehesten der, auf dem eine verlorene Zeile schon bilanziert. Die
|
||||
übrigen werden bei Stripe **archiviert** (Freigabe aus §5.1) und protokolliert.
|
||||
|
||||
**Kein beweisbar eigener Kandidat:** Verhalten wie heute (anlegen), mit Warnung
|
||||
im Log. Bewusst so: einen fremden Preis zu übernehmen ist schlimmer als einen
|
||||
zweiten anzulegen, und die Warnung nennt die ID, damit ein Betreiber entscheiden
|
||||
kann.
|
||||
|
||||
**Meldeweg ist das Log** (`Log::warning`), nicht die Konsolenausgabe: `ensure()`
|
||||
läuft auch im Web-Request, wo es keine Kommandozeile gibt.
|
||||
|
||||
## 7. Bauteil 2 — `App\Services\Stripe\IdempotencyKey`
|
||||
|
||||
Bildet aus dem sprechenden Schlüssel plus einem kurzen Fingerabdruck der
|
||||
**tatsächlich gesendeten Parameter** den Header-Wert:
|
||||
|
||||
```php
|
||||
IdempotencyKey::for('clupilot-addon-price-priority_support-month-3480-EUR', $payload)
|
||||
// → 'clupilot-addon-price-priority_support-month-3480-EUR-9f3a1c07'
|
||||
```
|
||||
|
||||
Kanonisch gebildet (Schlüssel sortiert, dann `sha1`, auf 8 Zeichen gekürzt),
|
||||
damit dieselbe Nutzlast immer denselben Fingerabdruck ergibt. In **einer**
|
||||
Support-Klasse, weil `HttpStripeClient` und `FakeStripeClient` denselben Wert
|
||||
bilden müssen — sonst prüft der Test etwas anderes, als die Produktion sendet.
|
||||
|
||||
**Angewandt nur auf `createPrice` und `createProduct`.** Bewusst **nicht** auf:
|
||||
|
||||
| Aufruf | Warum der 400 dort bleiben muss |
|
||||
|---|---|
|
||||
| `refund` | Ein zweiter Refund schickt dem Kunden das Geld zweimal. |
|
||||
| `cancelSubscription` | Andere Parameter unter demselben Schlüssel heißt: eine andere Kündigungsart als die schon abgeschickte. |
|
||||
| `addSubscriptionItem` | Ein zweites Item verrechnet dasselbe Modul doppelt. |
|
||||
| `createCheckoutSession` | Eine zweite Session zu geänderten Parametern ist ein zweiter Kaufvorgang. |
|
||||
|
||||
Die Regel lautet also: **der Fingerabdruck ist für die Katalogseite, wo ein
|
||||
Duplikat reparierbar und eine Blockade kundenwirksam ist.** Bei
|
||||
Geldbewegungen ist es umgekehrt. §11 schreibt diese Grenze als Test fest, damit
|
||||
sie niemand „vereinheitlicht".
|
||||
|
||||
## 8. Zwei neue Client-Methoden
|
||||
|
||||
```php
|
||||
/** @return array<int, array{id: string, unit_amount: int, currency: string, interval: string, created: int, metadata: array<string, string>}> */
|
||||
public function activePricesFor(string $productId): array;
|
||||
|
||||
public function updatePriceMetadata(string $priceId, array $metadata): void;
|
||||
```
|
||||
|
||||
`activePricesFor()` blättert nach dem Muster von `invoiceLines()`
|
||||
(`HttpStripeClient.php:312-336`) — ein Familien-Produkt sammelt über Versionen,
|
||||
Terme, Behandlungen und Steuersatzänderungen hinweg leicht mehr als eine Seite,
|
||||
und beim ersten Seitenende stehenzubleiben wäre dieselbe Lücke in neuer Form.
|
||||
|
||||
`updatePriceMetadata()` benutzt, dass Metadaten eines der wenigen Felder sind,
|
||||
die ein Stripe-Preis ändern lässt — dieselbe Eigenschaft, auf der
|
||||
`activatePrice()` schon beruht.
|
||||
|
||||
Beide im `FakeStripeClient` nachgezogen.
|
||||
|
||||
## 9. Der Fake muss Stripe erst einmal nachbilden
|
||||
|
||||
`FakeStripeClient::createPrice` spielt einen bekannten Schlüssel zurück, **ohne
|
||||
die Parameter zu vergleichen** (`:168-193`). Er ist genau in der Sache
|
||||
unrealistisch, um die es hier geht — deshalb konnte kein Test den Vorfall sehen.
|
||||
|
||||
Der Ledger `$keys` speichert künftig neben der ID auch die Nutzlast und wirft
|
||||
bei gleichem Schlüssel mit anderen Parametern — mit Stripes Wortlaut. Das ist
|
||||
der Regressionstest, der den 29.07. gesehen hätte.
|
||||
|
||||
## 10. Migration — eine Zeile pro Stripe-Preis
|
||||
|
||||
`2026_07_31_210000_one_row_per_stripe_price.php` (nach der letzten vorhandenen
|
||||
Migration einsortiert):
|
||||
|
||||
1. **Entdoppeln:** Zeilen in `stripe_addon_prices`, die eine
|
||||
`stripe_price_id` teilen — niedrigste Zeilen-ID bleibt, die übrigen werden
|
||||
**gelöscht**. Nicht archiviert: eine archivierte Zeile beansprucht die ID
|
||||
weiter und der eindeutige Index kennt `archived_at` nicht.
|
||||
2. **Index:** `stripe_price_id` wird eindeutig — wie auf der Paketseite seit
|
||||
`2026_07_30_110000:56`.
|
||||
|
||||
Löschen ist gefahrlos: die Zeile baut sich beim nächsten `ensure()` über die
|
||||
Wiedererkennung selbst wieder auf, und `subscription_addons.stripe_price_id`
|
||||
hält die Stripe-ID als **Text**, nicht als Fremdschlüssel.
|
||||
|
||||
Treiberneutral formuliert (Auswahl über `groupBy`/`having`, Löschen über
|
||||
IDs) — Produktion ist `mariadb`, Tests laufen auf `sqlite`.
|
||||
|
||||
**Was der Index absichert:** Bei einem Steuersatz von 0 % sind die Beträge
|
||||
beider Behandlungen gleich. Die Übernahme lehnt einen bereits beanspruchten
|
||||
Preis dann über §6 Bedingung 5 ab und legt für die zweite Behandlung einen
|
||||
eigenen an (der Schlüssel trägt `-rc`). Der Index ist das Netz darunter, falls
|
||||
diese Prüfung je gerissen wird.
|
||||
|
||||
## 11. Tests
|
||||
|
||||
Zwei Regeldatei-Tests nach Repo-Sitte, getrennt danach, **gegen wen** sie prüfen:
|
||||
|
||||
- `tests/Feature/Billing/StripeIdempotencyKeyTest.php` — was auf der Leitung
|
||||
landet. Läuft über `Http::fake()` gegen `HttpStripeClient`, weil geprüft werden
|
||||
muss, was **gesendet** wird, nicht was der Fake sich merkt. Hier steht auch,
|
||||
dass der Fake Stripes 400 überhaupt nachbildet.
|
||||
- `tests/Feature/Billing/StripePriceAdoptionTest.php` — die Übernahmeregeln,
|
||||
beide Seiten, über den `FakeStripeClient`.
|
||||
|
||||
| Prüfung | Deckt ab |
|
||||
|---|---|
|
||||
| Waise bei Stripe → wird übernommen, `createPrice` **nicht** aufgerufen, Zeile trägt die vorhandene ID (Modul **und** Paket) | Mangel 1 |
|
||||
| Waise mit **altem** Metadatenformat → wird übernommen, Metadaten bei Stripe angeglichen | der Vorfall selbst |
|
||||
| Zwei Waisen → älteste übernommen, andere bei Stripe archiviert, Warnung im Log | §6 |
|
||||
| Aktiver Preis an unserem Produkt **ohne** unsere Metadaten → **nicht** übernommen, neuer angelegt, Warnung | §6 Bedingung 4 |
|
||||
| Preis-ID schon von einer Zeile beansprucht → **nicht** zweimal vergeben | §6 Bedingung 5 |
|
||||
| **Eine eingefrorene Buchung behält ihren Betrag**: ein auf altem Preis laufendes Modul wird von einem Abgleich nicht verschoben | §5.2 |
|
||||
| Gleicher sprechender Schlüssel + geänderte Metadaten → **unterschiedlicher** Header, kein 400 | Mangel 2 |
|
||||
| Fingerabdruck **nur** bei `createPrice`/`createProduct`, nicht bei `refund`, `cancelSubscription`, `addSubscriptionItem`, `createCheckoutSession` | §7 |
|
||||
| Der Fake wirft bei gleichem Schlüssel mit anderen Parametern | §9 |
|
||||
| Zwei Zeilen können keine `stripe_price_id` teilen | §10 |
|
||||
|
||||
## 12. Nicht Teil dieses Vorhabens
|
||||
|
||||
- **Ein Aufräum-Kommando** für Waisen, die niemand mehr übernehmen kann (etwa
|
||||
ohne unsere Metadaten). Der Log-Eintrag nennt sie; ein Kommando dafür ist ein
|
||||
Folgepunkt.
|
||||
- **Die übrigen Block-D-Punkte** aus `docs/handoffs/2026-07-30-real-run-handoff.md`.
|
||||
- **`stripe:reprice-subscriptions`** und alles, was laufende Verträge bewegt.
|
||||
Dieser Entwurf bewegt keinen einzigen — siehe §5.
|
||||
- **Stripes `automatic_tax`** bleibt aus, `TaxTreatment` bleibt die einzige
|
||||
Steuerinstanz.
|
||||
|
||||
## 13. Berührte Dateien
|
||||
|
||||
**Neu**
|
||||
- `app/Services/Billing/AdoptStripePrice.php`
|
||||
- `app/Services/Stripe/IdempotencyKey.php`
|
||||
- `database/migrations/2026_07_31_210000_one_row_per_stripe_price.php`
|
||||
- `tests/Feature/Billing/StripeIdempotencyKeyTest.php`
|
||||
- `tests/Feature/Billing/StripePriceAdoptionTest.php`
|
||||
|
||||
**Geändert**
|
||||
- `app/Services/Stripe/StripeClient.php` — zwei Methoden
|
||||
- `app/Services/Stripe/HttpStripeClient.php` — Auflisten, Metadaten schreiben,
|
||||
Fingerabdruck bei den zwei Anlege-Aufrufen
|
||||
- `app/Services/Stripe/FakeStripeClient.php` — beide Methoden, Ledger mit
|
||||
Parametervergleich
|
||||
- `app/Services/Billing/AddonPrices.php` — Wiedererkennung vor `createPrice`,
|
||||
Kommentar zur Schlüssel-Begründung berichtigt
|
||||
- `app/Services/Billing/PlanPrices.php` — dasselbe
|
||||
- `docs/handoffs/2026-07-30-real-run-handoff.md` — Block-D-Punkt als erledigt
|
||||
markieren
|
||||
|
||||
---
|
||||
|
||||
## 14. Folgepunkte, die aus der Umsetzung entstanden sind
|
||||
|
||||
Nachgetragen nach dem Abschluss-Review. Alle Befunde sind offengelegt, keiner
|
||||
blockiert den Merge — aber keiner ist erledigt.
|
||||
|
||||
### Die eine echte Lücke: Waisen-**Produkte**
|
||||
|
||||
`activePricesFor()` erkennt einen verwaisten *Preis* wieder. Für ein verwaistes
|
||||
**Produkt** gibt es kein Gegenstück, und dort ist der Schaden größer: ein
|
||||
zweites Produkt lässt jedes `activePricesFor($neueId)` leer zurückkommen, damit
|
||||
ist die Wiedererkennung für dieses Modul oder diese Familie **dauerhaft blind**
|
||||
und jede Waise am ersten Produkt unerreichbar. Der Zweig deaktiviert seine
|
||||
eigene Sicherung durch die Lücke, die er nicht geschlossen hat.
|
||||
|
||||
Betroffen: `SyncStripeCatalogue::handle()` (Produkt anlegen, ID danach in einem
|
||||
zweiten Schreibvorgang speichern) und `AddonPrices::product()`. Beide Kommentare
|
||||
benennen die Lücke jetzt; der Fix wäre ein `productsFor()` nach dem Muster von
|
||||
`activePricesFor()`, das Stripe nach den Produkten fragt und über die Metadaten
|
||||
wiedererkennt.
|
||||
|
||||
### Das Geld-Tor sitzt an der falschen Stelle
|
||||
|
||||
`AdoptStripePrice` verspricht in seinem Kopfkommentar, keine Übernahme könne
|
||||
Geld verschieben — kann aber nur Betrag, Währung und Intervall vergleichen. Was
|
||||
sonst noch entscheidet, was ein Preis verrechnet (`interval_count`,
|
||||
`usage_type`, `transform_quantity`, `billing_scheme`), filtert
|
||||
`HttpStripeClient::activePricesFor()`. Zwei Folgen: der `FakeStripeClient` kann
|
||||
diese Felder gar nicht ausdrücken, also kann **kein Test von
|
||||
`AdoptStripePrice` das Geld-Tor abdecken**; und eine dritte Implementierung von
|
||||
`StripeClient` ließe es still fallen. Die Prüfung gehört in die Klasse, die sie
|
||||
zusagt.
|
||||
|
||||
Verwandt: `PlanPrices::ensure()` hat keine `<= 0`-Schranke, wo
|
||||
`AddonPrices::ensure()` eine hat. Das ist das Einzige, was einen
|
||||
`billing_scheme: tiered`-Preis (bei dem Stripe `unit_amount: null` liefert, hier
|
||||
als 0 gelesen) auf der Paketseite überhaupt erreichbar macht.
|
||||
|
||||
### Kleineres, benannt statt vergessen
|
||||
|
||||
- **Der Abgleich zählt „angelegt", auch wo er übernommen hat.** Die Wortwahl der
|
||||
Konsolenausgabe ist entschärft („created or adopted"), gezählt wird weiterhin
|
||||
vor dem Aufruf. Eine echte Trennung der beiden Zahlen fehlt — und das ist die
|
||||
eine Zahl, die ein Mensch nach einem Vorfall zuerst liest.
|
||||
- **Die Warnung über einen unerklärlichen Preis hat keine Drosselung.** Sie
|
||||
feuert bei jedem Abgleich *und* jeder Kundenbuchung, solange die Waise
|
||||
existiert — und weil das Aufräum-Kommando (§12) ein Folgepunkt ist, auf
|
||||
unbestimmte Zeit. Der Widerspruchs-Pfad wurde aus genau diesem Grund still
|
||||
gestellt; der Bestätigungs-Pfad bekam dieselbe Überlegung nicht.
|
||||
- **`FakeStripeClient::updatePriceMetadata()` ersetzt, wo Stripe zusammenführt**,
|
||||
und `activePricesFor()` gibt Metadaten ungecastet zurück, wo der HTTP-Client
|
||||
auf String castet. Heute nicht beobachtbar, aber es ist genau die Sorte
|
||||
Untreue des Fakes, gegen die §9 geschrieben wurde.
|
||||
- **`created` im Fake kann gleichstehen**: `createPrice()` stempelt
|
||||
`count($prices) + 1`, `plantPrice()` hat die Vorgabe `1`. Der Kommentar sagt
|
||||
das jetzt zu, statt es zu bestreiten; ein Gleichstand-Tiebreak auf die ID
|
||||
fehlt weiter.
|
||||
- **`HttpStripeClient::activePricesFor()` bricht das Blättern still ab**, wenn
|
||||
dem letzten Eintrag einer nicht-letzten Seite die `id` fehlt — getreue Kopie
|
||||
desselben Verhaltens in `invoiceLines()`. Die Folge hat sich aber geändert: in
|
||||
`invoiceLines()` entsteht ein zu kurzer Belegtext, hier eine ungesehene Waise
|
||||
und ein zweiter aktiver Preis, also der Vorfall. Wer es anfasst, soll werfen
|
||||
statt anhalten.
|
||||
- **Der Idempotenz-Fingerabdruck und der POST-Rumpf müssen zusammenbleiben.**
|
||||
Ein Feld, das künftig in `createPrice()` gesendet, aber nicht in
|
||||
`IdempotencyKey::priceParameters()` aufgenommen wird, öffnet den Vorfall
|
||||
wieder. Der Test „does not block a booking because the metadata format moved"
|
||||
wacht über diese Nahtstelle — mit einem gesetzten Ledger-Eintrag, weil der
|
||||
Zustand heute nicht mehr fahrbar ist.
|
||||
|
||||
### Zwei fremde, vorbestehende Testfehler, die dieser Zweig aufgedeckt hat
|
||||
|
||||
Keiner davon wird von diesem Zweig verursacht.
|
||||
|
||||
1. **`HostStepsTest.php:1064`** schreibt `fsn-01.node.clupilot.com` hart hinein,
|
||||
während `.env:107` `CLUPILOT_DNS_ZONE=clupilot.cloud` setzt und `phpunit.xml`
|
||||
neun andere Variablen mit `force="true"` festnagelt, diese aber nicht. Eine
|
||||
Zeile in `phpunit.xml`. Derselbe Punkt steht als offene Aufgabe in
|
||||
`docs/handoffs/2026-07-30-real-run-handoff.md`.
|
||||
2. **`PlanCatalogueTest.php`** ist einmal ausgefallen und lief beim
|
||||
Wiederholen durch. „Beim zweiten Mal grün" ist keine Diagnose, und ein
|
||||
sprunghafter Fehler in den Katalogpreis-Tests liegt im Wirkungskreis dieses
|
||||
Zweigs, auch wenn er nicht von ihm kommt.
|
||||
|
|
@ -11,6 +11,8 @@ return [
|
|||
],
|
||||
|
||||
'nav' => [
|
||||
'dpa' => 'AV-Vertrag',
|
||||
'mail_preview' => 'E-Mail-Vorschau',
|
||||
'inbox' => 'Posteingang',
|
||||
'mail_log' => 'Postausgang',
|
||||
'templates' => 'E-Mail-Vorlagen',
|
||||
|
|
@ -89,6 +91,9 @@ return [
|
|||
],
|
||||
|
||||
'notice' => [
|
||||
'mail_not_delivering' => 'MAIL_MAILER steht auf „:mailer“ — es wird keine E-Mail zugestellt, alles landet in storage/logs. Auch der Postfach-Test meldet trotzdem Erfolg, weil er seinen eigenen Weg nimmt.',
|
||||
'site_hidden' => 'Website und Kundenbereich sind ausgeblendet — Besucher außerhalb des VPN bekommen die Platzhalterseite (503). Umschalten unter Einstellungen → Installation.',
|
||||
'no_site_host' => 'SITE_HOST ist nicht gesetzt. Die Website antwortet dadurch auch auf :app, und Links auf ihr zeigen dorthin statt auf den eigenen Namen.',
|
||||
'no_capacity' => 'Kein Host hat noch Platz für „:plan“ — das Paket braucht :needs GB, der freieste Host hat :largest GB. Eine bezahlte Bestellung dafür würde bei der Reservierung scheitern.',
|
||||
'failed_runs' => ':n fehlgeschlagene Bereitstellung(en).',
|
||||
'host_error' => 'Host :host meldet einen Fehler.',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ return [
|
|||
'per_month' => 'netto pro Monat',
|
||||
|
||||
'net_reverse_charge' => 'netto — Reverse Charge',
|
||||
'term_yearly_note' => 'Jahreszahlung: :total netto im Jahr',
|
||||
'renews_at' => 'nächste Abrechnung :date',
|
||||
'net_per_month' => 'netto pro Monat',
|
||||
'granted_plan' => 'Bereitgestellt',
|
||||
'net_once' => 'netto, einmalig',
|
||||
|
|
@ -65,6 +67,9 @@ return [
|
|||
'traffic_cta' => ':gb GB nachbuchen',
|
||||
'title' => 'Paket & Addons',
|
||||
'subtitle' => 'Ihr aktuelles Paket, Upgrades, Zusatzspeicher und Erweiterungen.',
|
||||
'none_title' => 'Sie haben noch kein Paket gebucht',
|
||||
'none_body' => 'Hier stehen später Ihr Paket, Ihre Zusatzoptionen und was das monatlich kostet. Solange nichts gebucht ist, gibt es hier auch nichts zu ändern.',
|
||||
'none_cta' => 'Paket buchen',
|
||||
'current_plan' => 'Aktuelles Paket',
|
||||
'per_month' => 'pro Monat',
|
||||
'month_short' => 'Mon.',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,33 @@ 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.',
|
||||
'immediate_start_required' => 'Bitte bestätigen Sie, dass die Leistung sofort beginnen soll — sonst können wir Ihre Cloud erst nach Ablauf der Widerrufsfrist einrichten.',
|
||||
// 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.',
|
||||
|
||||
// The order page before Stripe — App\Livewire\Checkout.
|
||||
'eyebrow' => 'Bestellung',
|
||||
'title' => 'Bestellung prüfen',
|
||||
'back_to_plans' => 'Andere Pakete',
|
||||
'unknown_plan' => 'Dieses Paket steht nicht (mehr) zum Verkauf.',
|
||||
'performance' => 'Leistung',
|
||||
'summary_title' => 'Zusammenfassung',
|
||||
'setup_line' => 'Einmalige Einrichtung',
|
||||
'tax_line' => 'Umsatzsteuer :rate %',
|
||||
'total_today' => 'Heute zu zahlen',
|
||||
'then_monthly' => 'Danach :amount pro Monat',
|
||||
'then_yearly' => 'Danach :amount pro Jahr',
|
||||
'pay_cta' => 'Zahlungspflichtig bestellen',
|
||||
'stripe_note' => 'Weiter zu Stripe. Kartendaten erreichen unsere Server nie.',
|
||||
'next_title' => 'Was danach passiert',
|
||||
'step_pay_title' => 'Zahlung bei Stripe',
|
||||
'step_pay_body' => 'Sie geben Ihre Zahlungsdaten bei Stripe ein und kommen anschließend hierher zurück.',
|
||||
'step_build_title' => 'Ihre Cloud wird aufgesetzt',
|
||||
'step_build_body' => 'Das beginnt, sobald die Zahlung eingegangen ist, und läuft ohne Ihr Zutun.',
|
||||
'step_credentials_title' => 'Zugangsdaten per E-Mail',
|
||||
'step_credentials_body' => 'Sobald die Cloud bereitsteht, bekommen Sie Adresse und Zugangsdaten an Ihre bestätigte Adresse.',
|
||||
'note_withdrawal_title' => '14 Tage Widerrufsrecht',
|
||||
'note_withdrawal_body' => 'Als Verbraucher treten Sie binnen 14 Tagen ohne Angabe von Gründen zurück und erhalten den gesamten Betrag zurück.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
// Der Auftragsverarbeitungsvertrag, wie ihn der Kunde sieht. Der TEXT des
|
||||
// Vertrags liegt nicht hier — er wird in der Konsole hochgeladen.
|
||||
return [
|
||||
'title' => 'AV-Vertrag & TOM',
|
||||
'sub' => 'Der Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO samt Dokumentation der technischen und organisatorischen Maßnahmen — für Ihre eigene Dokumentation und Prüfung.',
|
||||
'read_agreement' => 'AV-Vertrag ansehen',
|
||||
'read_measures' => 'TOM ansehen',
|
||||
'version' => 'Fassung :version',
|
||||
'accept_cta' => 'Zusätzlich bestätigen',
|
||||
'accepted_on' => 'Fassung :version von Ihnen bestätigt am :when. Bei einer neuen Fassung informieren wir Sie.',
|
||||
'accepted_notice' => 'Bestätigung festgehalten. Den Vertrag finden Sie jederzeit hier.',
|
||||
'download' => 'Herunterladen',
|
||||
'in_force_note' => 'Dieser Vertrag ist mit Ihrer Bestellung Bestandteil unserer Vereinbarung; eine gesonderte Unterschrift ist nicht nötig. Wenn Sie für Ihre Unterlagen eine ausdrückliche Bestätigung möchten, halten wir Fassung, Zeitpunkt und IP-Adresse fest.',
|
||||
'download_agreement' => 'AV-Vertrag laden',
|
||||
'download_measures' => 'TOM laden',
|
||||
];
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Rechtliches',
|
||||
'title' => 'AV-Vertrag & TOM',
|
||||
'subtitle' => 'Das Dokument, das Ihre Kunden nach Art. 28 DSGVO abschließen. Den Text laden Sie hoch — geschrieben wird er von Ihrer Rechtsberatung, nicht von dieser Anwendung.',
|
||||
'upload_title' => 'Neue Fassung hochladen',
|
||||
'upload_sub' => 'PDF, höchstens 10 MB. Die TOM sind eine Anlage zum Vertrag und werden mit derselben Fassung geführt.',
|
||||
'version' => 'Bezeichnung der Fassung',
|
||||
'version_hint' => 'Wie das Dokument selbst sie nennt — „1.0", „2026-08".',
|
||||
'note' => 'Interne Notiz',
|
||||
'note_hint' => 'Optional, nur für die Konsole.',
|
||||
'file_agreement' => 'AV-Vertrag (PDF)',
|
||||
'file_measures' => 'TOM (PDF)',
|
||||
'upload_cta' => 'Hochladen',
|
||||
'uploaded' => 'Fassung :version hochgeladen. Sie ist noch nicht in Kraft.',
|
||||
'current_title' => 'In Kraft',
|
||||
'none_yet' => 'Noch keine Fassung veröffentlicht. Solange nichts in Kraft ist, sehen Ihre Kunden den Punkt nicht.',
|
||||
'in_force_since' => 'seit :when',
|
||||
'accepted_count' => ':accepted von :total Kunden haben zusätzlich bestätigt.',
|
||||
'outstanding' => 'Eine Bestätigung ist nicht erforderlich — der Vertrag gilt mit den AGB.',
|
||||
'versions_title' => 'Fassungen',
|
||||
'no_versions' => 'Noch nichts hochgeladen.',
|
||||
'col_version' => 'Fassung',
|
||||
'col_state' => 'Status',
|
||||
'col_accepted' => 'Abschlüsse',
|
||||
'col_files' => 'Dateien',
|
||||
'state_current' => 'In Kraft',
|
||||
'state_superseded' => 'Abgelöst',
|
||||
'state_draft' => 'Entwurf',
|
||||
'publish' => 'In Kraft setzen',
|
||||
'published' => 'Fassung :version ist in Kraft.',
|
||||
'publish_title' => 'Fassung :version in Kraft setzen?',
|
||||
'publish_body' => 'Ab diesem Moment gilt diese Fassung. Alle Kunden, die die bisherige abgeschlossen hatten, sind wieder offen und müssen erneut zustimmen.',
|
||||
'download' => 'Herunterladen',
|
||||
];
|
||||
|
|
@ -8,6 +8,9 @@ return [
|
|||
'fetch_now' => 'Jetzt abrufen',
|
||||
'fetched' => '{1} :count neue Nachricht|[2,*] :count neue Nachrichten',
|
||||
'nothing_new' => 'Nichts Neues im Postfach.',
|
||||
'never_checked' => 'Das Postfach wurde noch nie abgerufen — der nächste Lauf ist in wenigen Minuten.',
|
||||
'last_ok' => 'Postfach zuletzt erreicht: :when (:ago)',
|
||||
'last_failed' => 'Postfach zuletzt nicht erreichbar: :when (:ago).',
|
||||
|
||||
'show_archived' => 'Erledigte zeigen',
|
||||
'show_open' => 'Offene zeigen',
|
||||
|
|
|
|||
|
|
@ -102,6 +102,17 @@ return [
|
|||
'inbound_port_hint' => '993 ist IMAP über TLS. Alles andere läuft unverschlüsselt — dieser Zugang trägt ein Passwort und die Worte Ihrer Kunden.',
|
||||
'inbound_username' => 'Postfach',
|
||||
'inbound_folder' => 'Ordner',
|
||||
'inbound_test' => 'Verbindung prüfen',
|
||||
'inbound_never' => 'Noch nie geprüft.',
|
||||
'inbound_last_ok' => 'Zuletzt erreichbar: :when (:ago)',
|
||||
'inbound_last_failed' => 'Zuletzt fehlgeschlagen: :when (:ago)',
|
||||
'inbound_ok' => 'Postfach erreichbar.',
|
||||
'inbound_ok_waiting' => '{0} Postfach erreichbar, nichts Ungelesenes.|{1} Postfach erreichbar, :count ungelesene Nachricht.|[2,*] Postfach erreichbar, :count ungelesene Nachrichten.',
|
||||
'inbound_failed_incomplete' => 'Es fehlen noch Angaben: Server, Postfach oder Passwort.',
|
||||
'inbound_failed_unreachable' => 'Der Server ist nicht erreichbar. Stimmen Adresse und Port?',
|
||||
'inbound_failed_credentials' => 'Der Server hat die Anmeldung abgelehnt. Stimmen Postfach und Passwort?',
|
||||
'inbound_failed_folder' => 'Anmeldung hat funktioniert, den Ordner gibt es aber nicht.',
|
||||
'inbound_failed_failed' => 'Die Verbindung ist fehlgeschlagen. Näheres steht im Log.',
|
||||
|
||||
// Die Reiter. Getrennt nach dem, wofür ein Abschnitt da ist — nicht danach,
|
||||
// welcher Speichermechanismus einen Wert zufällig hält.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
// Eine Mail ansehen, bevor ein Kunde sie sieht.
|
||||
return [
|
||||
'title' => 'E-Mail-Vorschau',
|
||||
'subtitle' => 'Jede Nachricht, die diese Installation verschickt, mit Beispieldaten gefüllt. Im Browser ansehen beantwortet die Frage am Rechner; als echte Mail an sich selbst schicken ist der einzige Weg, es am Telefon zu beurteilen.',
|
||||
'open' => 'Im Browser',
|
||||
'send' => 'An mich senden',
|
||||
'sent' => 'Vorschau an :email geschickt.',
|
||||
'failed' => 'Konnte nicht geschickt werden: :reason',
|
||||
'note' => 'Gesendet wird immer an Ihre eigene Adresse (:email) — es gibt bewusst kein Empfängerfeld. Die Beispieldaten sind erfunden und werden nicht gespeichert.',
|
||||
];
|
||||
|
|
@ -7,7 +7,31 @@ 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.',
|
||||
'term_monthly' => 'Monatlich',
|
||||
'term_yearly' => 'Jährlich',
|
||||
'free_months_taken' => '{1} 1 Monat gratis enthalten|[2,*] :count Monate gratis enthalten',
|
||||
'free_months_hint' => '{1} 1 Monat gratis bei Jahreszahlung|[2,*] :count Monate gratis bei Jahreszahlung',
|
||||
'yearly_total' => ':total jährlich',
|
||||
'instead_of' => 'statt :amount',
|
||||
'eyebrow' => 'Pakete',
|
||||
'choose' => 'Weiter',
|
||||
'per_year' => '/Jahr',
|
||||
'per_month_label' => 'monatlich',
|
||||
'per_year_label' => 'jährlich',
|
||||
'per_month_equivalent' => 'entspricht :amount pro Monat',
|
||||
'note_cancel_title' => 'Monatlich kündbar',
|
||||
'note_cancel_body' => 'Keine Mindestlaufzeit. Sie kündigen im Kundenbereich zum Ende der laufenden Periode und erhalten davor einen vollständigen Datenexport.',
|
||||
'note_payment_title' => 'Zahlung über Stripe',
|
||||
'note_payment_body' => 'Kartendaten erreichen unsere Server nie. Die Zahlung läuft über Stripe; wir sehen nur, dass sie erfolgt ist.',
|
||||
'note_migration_title' => 'Datenübernahme',
|
||||
'note_migration_body' => 'Nicht Teil des Pakets — jede Ausgangslage ist anders. Schreiben Sie uns, wir sehen sie uns an und machen ein Angebot.',
|
||||
'recommended' => 'Empfohlen',
|
||||
'up_to' => 'bis :count',
|
||||
'per_month' => '/Monat',
|
||||
'incl_vat' => 'inkl. :rate % MwSt.',
|
||||
// Für ein bestätigtes EU-Unternehmen außerhalb Österreichs: keine Umsatzsteuer,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ return [
|
|||
'cents_hint' => 'In Cent, netto.',
|
||||
'disk_hint' => 'Mindestens so groß wie der Speicher.',
|
||||
'net' => 'netto',
|
||||
'free_months' => 'Gratis-Monate im Jahr',
|
||||
'free_months_hint' => '0 bis 6. Bei 2 zahlt der Kunde 10 Monate und bekommt 12.',
|
||||
'yearly_preview' => 'Jahrespreis: :total netto (:months Monate bezahlt)',
|
||||
'free_months_note' => '{1} ein Monat gratis|[2,*] :count Monate gratis',
|
||||
'term_monthly' => 'Monatlich',
|
||||
'term_yearly' => 'Jährlich',
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Konto',
|
||||
'company_sub' => 'Diese Angaben stehen auf Ihren Rechnungen.',
|
||||
// The tab bar. The list in App\Livewire\Settings::TABS decides which
|
||||
// exist; these only name them.
|
||||
'tab' => [
|
||||
'profile' => 'Ihre Daten',
|
||||
'security' => 'Sicherheit',
|
||||
'branding' => 'Erscheinungsbild',
|
||||
'contract' => 'Vertrag',
|
||||
],
|
||||
'title' => 'Einstellungen',
|
||||
'subtitle' => 'Firmendaten, Branding und Ihr Paket verwalten.',
|
||||
'subtitle' => 'Alles, was zu Ihrem Konto gehört — nach Themen getrennt.',
|
||||
'save' => 'Speichern',
|
||||
|
||||
'company_title' => 'Firmendaten',
|
||||
|
|
@ -10,6 +20,12 @@ return [
|
|||
'contact_name' => 'Ansprechpartner',
|
||||
'phone' => 'Telefon',
|
||||
'vat_id' => 'USt-IdNr.',
|
||||
'billing_street' => 'Straße und Hausnummer',
|
||||
'billing_postcode' => 'PLZ',
|
||||
'billing_city' => 'Ort',
|
||||
'billing_country' => 'Land',
|
||||
'customer_type_locked' => 'Bei der Registrierung angegeben. Eine Änderung nehmen wir nur nach Rückfrage vor — schreiben Sie uns.',
|
||||
'customer_type_once' => 'Diese Angabe ist noch offen. Sie lässt sich einmal setzen und danach nur noch über den Support ändern.',
|
||||
'billing_address' => 'Rechnungsadresse',
|
||||
'profile_saved' => 'Firmendaten gespeichert.',
|
||||
|
||||
|
|
@ -33,6 +49,9 @@ return [
|
|||
'brand_using_default' => 'Es werden aktuell die CluPilot-Standardwerte verwendet.',
|
||||
'branding_saved' => 'Branding gespeichert.',
|
||||
|
||||
'contract_title' => 'Vertrag und Konto',
|
||||
'contract_sub' => 'Ihr Paket, Ihre Rechte daraus und die Unterlagen dazu.',
|
||||
'to_packages' => 'Pakete ansehen',
|
||||
'package_title' => 'Paket',
|
||||
'package_active' => 'Aktives Paket: :plan.',
|
||||
'no_package' => 'Kein aktives Paket.',
|
||||
|
|
@ -45,12 +64,34 @@ return [
|
|||
'cancel_point_term' => 'Wirksam zum Ende der Laufzeit — bis dahin bleibt alles verfügbar.',
|
||||
'cancel_point_export' => 'Zum Laufzeitende erhalten Sie einen vollständigen Datenexport.',
|
||||
'cancel_point_irreversible' => 'Die Kündigung ist nach Bestätigung verbindlich.',
|
||||
'cancel_reason_label' => 'Warum kündigen Sie?',
|
||||
'cancel_reason_choose' => 'Bitte wählen',
|
||||
'cancel_reason_required' => 'Bitte wählen Sie einen Grund.',
|
||||
'cancel_note_label' => 'Möchten Sie uns etwas dazu sagen? (optional)',
|
||||
'cancel_note_label_required' => 'Bitte sagen Sie uns kurz, worum es geht.',
|
||||
'cancel_note_required' => 'Bei „Sonstiges" brauchen wir eine kurze Angabe.',
|
||||
// The keys live in Subscription::CANCEL_REASONS; only the wording is here.
|
||||
'cancel_reason' => [
|
||||
'too_expensive' => 'Zu teuer',
|
||||
'missing_features' => 'Funktionen fehlen',
|
||||
'switching_provider' => 'Wechsel zu einem anderen Anbieter',
|
||||
'no_longer_needed' => 'Wird nicht mehr gebraucht',
|
||||
'performance' => 'Geschwindigkeit oder Verfügbarkeit',
|
||||
'support' => 'Unzufrieden mit der Betreuung',
|
||||
'other' => 'Sonstiges',
|
||||
],
|
||||
'cancel_confirm_label' => 'Zum Bestätigen „:name" eingeben:',
|
||||
'cancel_confirm' => 'Verbindlich kündigen',
|
||||
'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ return [
|
|||
// Ausdrücklich: ohne Bestätigung passiert nichts weiter. Wer hier
|
||||
// fälschlich eine fremde Adresse eingetragen hat, soll den Empfänger nicht
|
||||
// zu einer Handlung zwingen.
|
||||
'not_you' => 'Sie haben sich nicht angemeldet? Dann ignorieren Sie diese Nachricht. Ohne Bestätigung wird das Konto nicht freigeschaltet, und wir schreiben Ihnen nicht wieder.',
|
||||
'not_you' => 'Sie haben sich nicht angemeldet? Dann ignorieren Sie diese Nachricht. Ohne Bestätigung wird das Konto nicht freigeschaltet, nach fünf Tagen von selbst gelöscht, und wir schreiben Ihnen nicht wieder.',
|
||||
|
||||
// Die Seite, auf der jemand landet, der sich angemeldet hat aber noch nicht
|
||||
// bestätigt ist.
|
||||
|
|
@ -21,6 +21,15 @@ return [
|
|||
'notice_hint' => 'Nichts angekommen? Sehen Sie im Spam-Ordner nach — oder schicken Sie die Nachricht erneut.',
|
||||
'notice_resend' => 'Erneut schicken',
|
||||
'notice_sent' => 'Bestätigung erneut verschickt.',
|
||||
'notice_signout' => 'Abmelden',
|
||||
'notice_wrong_address' => 'Adresse falsch eingetragen? Melden Sie sich ab und registrieren Sie sich erneut.',
|
||||
'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',
|
||||
'delete_confirm' => 'Verwerfen',
|
||||
'deleted' => 'Registrierung verworfen. Sie können sich mit derselben Adresse neu anmelden.',
|
||||
'delete_has_contract' => 'Zu diesem Zugang gehört bereits ein Vertrag — er lässt sich hier nicht verwerfen. Schreiben Sie uns, wir klären das.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
return [
|
||||
'card_title' => 'Widerrufsrecht',
|
||||
'card_sub' => 'Sie können diesen Vertrag noch bis zum :date widerrufen — :days Tag(e). Der Dienst endet dann sofort und Sie erhalten den gesamten gezahlten Betrag zurück.',
|
||||
'instead_title' => 'Sie können noch widerrufen',
|
||||
'instead_body' => 'Bis :date können Sie stattdessen vom Vertrag zurücktreten: Sie erhalten den gesamten gezahlten Betrag zurück, die Cloud endet dann allerdings sofort statt zum Ende der bezahlten Periode.',
|
||||
'cta' => 'Widerruf erklären',
|
||||
'keep' => 'Abbrechen',
|
||||
'confirm' => 'Widerruf erklären',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ return [
|
|||
],
|
||||
|
||||
'nav' => [
|
||||
'dpa' => 'Processing agreement',
|
||||
'mail_preview' => 'Mail preview',
|
||||
'inbox' => 'Inbox',
|
||||
'mail_log' => 'Mail sent',
|
||||
'templates' => 'Mail templates',
|
||||
|
|
@ -89,6 +91,9 @@ return [
|
|||
],
|
||||
|
||||
'notice' => [
|
||||
'mail_not_delivering' => 'MAIL_MAILER is set to ":mailer" — no mail is delivered to anybody, it all goes to storage/logs. The mailbox test still reports success, because it builds its own transport.',
|
||||
'site_hidden' => 'The website and the customer portal are hidden — a visitor outside the VPN gets the placeholder page (503). The switch is under Settings → Installation.',
|
||||
'no_site_host' => 'SITE_HOST is not set. The website therefore also answers on :app, and links on it point there instead of at its own name.',
|
||||
'no_capacity' => 'No host has room for “:plan” any more — the package needs :needs GB and the roomiest host has :largest GB. A paid order for it would fail at reservation.',
|
||||
'failed_runs' => ':n failed provisioning run(s).',
|
||||
'host_error' => 'Host :host reports an error.',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ return [
|
|||
'per_month' => 'net per month',
|
||||
|
||||
'net_reverse_charge' => 'net — reverse charge',
|
||||
'term_yearly_note' => 'Paid yearly: :total net per year',
|
||||
'renews_at' => 'next charge :date',
|
||||
'net_per_month' => 'net per month',
|
||||
'granted_plan' => 'Provided',
|
||||
'net_once' => 'net, one-off',
|
||||
|
|
@ -65,6 +67,9 @@ return [
|
|||
'traffic_cta' => 'Add :gb GB',
|
||||
'title' => 'Plan & add-ons',
|
||||
'subtitle' => 'Your current plan, upgrades, extra storage and add-ons.',
|
||||
'none_title' => 'You have not booked a package yet',
|
||||
'none_body' => 'Your package, your extras and what they cost per month will be here. While nothing is booked there is nothing to change here either.',
|
||||
'none_cta' => 'Book a package',
|
||||
'current_plan' => 'Current plan',
|
||||
'per_month' => 'per month',
|
||||
'month_short' => 'mo',
|
||||
|
|
|
|||
|
|
@ -12,14 +12,37 @@ 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' => 'Please confirm that the service should begin at once — otherwise we can only build your cloud once the withdrawal period has ended.',
|
||||
// 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.',
|
||||
|
||||
// The order page before Stripe — App\Livewire\Checkout.
|
||||
'eyebrow' => 'Order',
|
||||
'title' => 'Review your order',
|
||||
'back_to_plans' => 'Other packages',
|
||||
'unknown_plan' => 'This package is not on sale (any more).',
|
||||
'performance' => 'Performance',
|
||||
'summary_title' => 'Summary',
|
||||
'setup_line' => 'One-off setup',
|
||||
'tax_line' => 'VAT :rate %',
|
||||
'total_today' => 'Due today',
|
||||
'then_monthly' => 'Then :amount per month',
|
||||
'then_yearly' => 'Then :amount per year',
|
||||
'pay_cta' => 'Place binding order',
|
||||
'stripe_note' => 'On to Stripe. Card details never reach our servers.',
|
||||
'next_title' => 'What happens next',
|
||||
'step_pay_title' => 'Payment at Stripe',
|
||||
'step_pay_body' => 'You enter your payment details at Stripe and come back here afterwards.',
|
||||
'step_build_title' => 'Your cloud is built',
|
||||
'step_build_body' => 'That starts as soon as the payment lands and runs without you.',
|
||||
'step_credentials_title' => 'Credentials by mail',
|
||||
'step_credentials_body' => 'As soon as the cloud is ready you get the address and credentials at your confirmed address.',
|
||||
'note_withdrawal_title' => '14-day right of withdrawal',
|
||||
'note_withdrawal_body' => 'As a consumer you may withdraw within 14 days without giving a reason and get the full amount back.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
// The processing agreement as the customer sees it. The TEXT of the agreement
|
||||
// is not here — it is uploaded in the console.
|
||||
return [
|
||||
'title' => 'Processing agreement & TOMs',
|
||||
'sub' => 'The data-processing agreement under Art. 28 GDPR together with the documentation of technical and organisational measures — for your own records and audits.',
|
||||
'read_agreement' => 'Read the agreement',
|
||||
'read_measures' => 'Read the measures',
|
||||
'version' => 'Version :version',
|
||||
'accept_cta' => 'Confirm additionally',
|
||||
'accepted_on' => 'Version :version confirmed by you on :when. We will tell you when a new version is issued.',
|
||||
'accepted_notice' => 'Confirmation recorded. The agreement stays available here.',
|
||||
'download' => 'Download',
|
||||
'in_force_note' => 'This agreement became part of our contract with your order; a separate signature is not required. If you would like an explicit confirmation for your records, we note the version, the moment and the IP address.',
|
||||
'download_agreement' => 'Download agreement',
|
||||
'download_measures' => 'Download measures',
|
||||
];
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Legal',
|
||||
'title' => 'Processing agreement & TOMs',
|
||||
'subtitle' => 'The document your customers conclude under Art. 28 GDPR. You upload the text — it is written by your lawyer, not by this application.',
|
||||
'upload_title' => 'Upload a new version',
|
||||
'upload_sub' => 'PDF, at most 10 MB. The measures are an annex to the agreement and are versioned with it.',
|
||||
'version' => 'Version label',
|
||||
'version_hint' => 'What the document itself calls it — "1.0", "2026-08".',
|
||||
'note' => 'Internal note',
|
||||
'note_hint' => 'Optional, console only.',
|
||||
'file_agreement' => 'Agreement (PDF)',
|
||||
'file_measures' => 'Measures (PDF)',
|
||||
'upload_cta' => 'Upload',
|
||||
'uploaded' => 'Version :version uploaded. It is not in force yet.',
|
||||
'current_title' => 'In force',
|
||||
'none_yet' => 'No version published yet. While nothing is in force, customers do not see the section.',
|
||||
'in_force_since' => 'since :when',
|
||||
'accepted_count' => ':accepted of :total customers confirmed it additionally.',
|
||||
'outstanding' => 'A confirmation is not required — the agreement applies with the terms.',
|
||||
'versions_title' => 'Versions',
|
||||
'no_versions' => 'Nothing uploaded yet.',
|
||||
'col_version' => 'Version',
|
||||
'col_state' => 'State',
|
||||
'col_accepted' => 'Conclusions',
|
||||
'col_files' => 'Files',
|
||||
'state_current' => 'In force',
|
||||
'state_superseded' => 'Superseded',
|
||||
'state_draft' => 'Draft',
|
||||
'publish' => 'Put in force',
|
||||
'published' => 'Version :version is in force.',
|
||||
'publish_title' => 'Put version :version in force?',
|
||||
'publish_body' => 'From this moment this version applies. Every customer who had concluded the previous one is outstanding again and must accept anew.',
|
||||
'download' => 'Download',
|
||||
];
|
||||
|
|
@ -8,6 +8,9 @@ return [
|
|||
'fetch_now' => 'Fetch now',
|
||||
'fetched' => '{1} :count new message|[2,*] :count new messages',
|
||||
'nothing_new' => 'Nothing new in the mailbox.',
|
||||
'never_checked' => 'The mailbox has never been fetched yet — the next run is a few minutes away.',
|
||||
'last_ok' => 'Mailbox last reached: :when (:ago)',
|
||||
'last_failed' => 'Mailbox last unreachable: :when (:ago).',
|
||||
|
||||
'show_archived' => 'Show filed',
|
||||
'show_open' => 'Show open',
|
||||
|
|
|
|||
|
|
@ -102,6 +102,17 @@ return [
|
|||
'inbound_port_hint' => '993 is IMAP over TLS. Anything else is unencrypted — and this connection carries a password and your customers own words.',
|
||||
'inbound_username' => 'Mailbox',
|
||||
'inbound_folder' => 'Folder',
|
||||
'inbound_test' => 'Test the connection',
|
||||
'inbound_never' => 'Never checked.',
|
||||
'inbound_last_ok' => 'Last reached: :when (:ago)',
|
||||
'inbound_last_failed' => 'Last failed: :when (:ago)',
|
||||
'inbound_ok' => 'Mailbox reachable.',
|
||||
'inbound_ok_waiting' => '{0} Mailbox reachable, nothing unread.|{1} Mailbox reachable, :count unread message.|[2,*] Mailbox reachable, :count unread messages.',
|
||||
'inbound_failed_incomplete' => 'Something is still missing: server, mailbox or password.',
|
||||
'inbound_failed_unreachable' => 'The server cannot be reached. Are the address and port right?',
|
||||
'inbound_failed_credentials' => 'The server refused the sign-in. Are the mailbox and password right?',
|
||||
'inbound_failed_folder' => 'The sign-in worked, but that folder does not exist.',
|
||||
'inbound_failed_failed' => 'The connection failed. There is more in the log.',
|
||||
|
||||
// The tabs. Split by what a section is for — not by which storage
|
||||
// mechanism happens to hold a given value.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
// Looking at a mail before a customer does.
|
||||
return [
|
||||
'title' => 'Mail preview',
|
||||
'subtitle' => 'Every message this installation sends, filled with sample data. Opening it in the browser answers the desktop question; sending it to yourself as a real mail is the only way to judge it on a phone.',
|
||||
'open' => 'In the browser',
|
||||
'send' => 'Send to me',
|
||||
'sent' => 'Preview sent to :email.',
|
||||
'failed' => 'Could not be sent: :reason',
|
||||
'note' => 'Always sent to your own address (:email) — there is deliberately no recipient field. The sample data is invented and is not stored.',
|
||||
];
|
||||
|
|
@ -7,7 +7,31 @@ 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.',
|
||||
'term_monthly' => 'Monthly',
|
||||
'term_yearly' => 'Yearly',
|
||||
'free_months_taken' => '{1} 1 month free included|[2,*] :count months free included',
|
||||
'free_months_hint' => '{1} 1 month free when paying yearly|[2,*] :count months free when paying yearly',
|
||||
'yearly_total' => ':total per year',
|
||||
'instead_of' => 'instead of :amount',
|
||||
'eyebrow' => 'Packages',
|
||||
'choose' => 'Continue',
|
||||
'per_year' => '/year',
|
||||
'per_month_label' => 'monthly',
|
||||
'per_year_label' => 'yearly',
|
||||
'per_month_equivalent' => 'that is :amount per month',
|
||||
'note_cancel_title' => 'Cancel monthly',
|
||||
'note_cancel_body' => 'No minimum term. Cancel in the portal to the end of the current period; you get a full data export before it ends.',
|
||||
'note_payment_title' => 'Payment via Stripe',
|
||||
'note_payment_body' => 'Card details never reach our servers. Stripe handles the payment; we only learn that it happened.',
|
||||
'note_migration_title' => 'Data migration',
|
||||
'note_migration_body' => 'Not part of the package — every starting point differs. Write to us, we will look at yours and quote for it.',
|
||||
'recommended' => 'Recommended',
|
||||
'up_to' => 'up to :count',
|
||||
'per_month' => '/month',
|
||||
'incl_vat' => 'incl. :rate % VAT',
|
||||
// For a verified EU business outside Austria: no VAT, and therefore no second
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ return [
|
|||
'cents_hint' => 'In cents, net.',
|
||||
'disk_hint' => 'At least as large as the storage quota.',
|
||||
'net' => 'net',
|
||||
'free_months' => 'Free months per year',
|
||||
'free_months_hint' => '0 to 6. At 2 the customer pays for 10 months and gets 12.',
|
||||
'yearly_preview' => 'Yearly price: :total net (:months months paid)',
|
||||
'free_months_note' => '{1} one month free|[2,*] :count months free',
|
||||
'term_monthly' => 'Monthly',
|
||||
'term_yearly' => 'Yearly',
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Account',
|
||||
'company_sub' => 'These details appear on your invoices.',
|
||||
// The tab bar. The list in App\Livewire\Settings::TABS decides which
|
||||
// exist; these only name them.
|
||||
'tab' => [
|
||||
'profile' => 'Your details',
|
||||
'security' => 'Security',
|
||||
'branding' => 'Appearance',
|
||||
'contract' => 'Contract',
|
||||
],
|
||||
'title' => 'Settings',
|
||||
'subtitle' => 'Manage your company details, branding and package.',
|
||||
'subtitle' => 'Everything that belongs to your account, grouped by subject.',
|
||||
'save' => 'Save',
|
||||
|
||||
'company_title' => 'Company details',
|
||||
|
|
@ -10,6 +20,12 @@ return [
|
|||
'contact_name' => 'Contact person',
|
||||
'phone' => 'Phone',
|
||||
'vat_id' => 'VAT ID',
|
||||
'billing_street' => 'Street and number',
|
||||
'billing_postcode' => 'Postcode',
|
||||
'billing_city' => 'Town',
|
||||
'billing_country' => 'Country',
|
||||
'customer_type_locked' => 'Given at registration. We only change it after asking — write to us.',
|
||||
'customer_type_once' => 'Not answered yet. It can be set once and changed afterwards only through support.',
|
||||
'billing_address' => 'Billing address',
|
||||
'profile_saved' => 'Company details saved.',
|
||||
|
||||
|
|
@ -33,6 +49,9 @@ return [
|
|||
'brand_using_default' => 'Currently using the CluPilot defaults.',
|
||||
'branding_saved' => 'Branding saved.',
|
||||
|
||||
'contract_title' => 'Contract and account',
|
||||
'contract_sub' => 'Your package, the rights that come with it, and the documents.',
|
||||
'to_packages' => 'See the packages',
|
||||
'package_title' => 'Package',
|
||||
'package_active' => 'Active package: :plan.',
|
||||
'no_package' => 'No active package.',
|
||||
|
|
@ -45,12 +64,34 @@ return [
|
|||
'cancel_point_term' => 'Effective at the end of the term — everything stays available until then.',
|
||||
'cancel_point_export' => 'At the end of the term you receive a full data export.',
|
||||
'cancel_point_irreversible' => 'Once confirmed, the cancellation is binding.',
|
||||
'cancel_reason_label' => 'Why are you cancelling?',
|
||||
'cancel_reason_choose' => 'Please choose',
|
||||
'cancel_reason_required' => 'Please choose a reason.',
|
||||
'cancel_note_label' => 'Anything you would like to tell us? (optional)',
|
||||
'cancel_note_label_required' => 'Please tell us briefly what it is about.',
|
||||
'cancel_note_required' => 'With "other" we need a short note.',
|
||||
// The keys live in Subscription::CANCEL_REASONS; only the wording is here.
|
||||
'cancel_reason' => [
|
||||
'too_expensive' => 'Too expensive',
|
||||
'missing_features' => 'Missing features',
|
||||
'switching_provider' => 'Moving to another provider',
|
||||
'no_longer_needed' => 'No longer needed',
|
||||
'performance' => 'Speed or availability',
|
||||
'support' => 'Unhappy with the support',
|
||||
'other' => 'Other',
|
||||
],
|
||||
'cancel_confirm_label' => 'Type “:name” to confirm:',
|
||||
'cancel_confirm' => 'Cancel for good',
|
||||
'cancel_mismatch' => 'That does not match your cloud address.',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ return [
|
|||
'fallback' => 'If the button does not work, copy this address into your browser:',
|
||||
// Said plainly: without confirmation nothing further happens. Somebody who
|
||||
// typed a stranger's address must not force that stranger to act.
|
||||
'not_you' => 'Did not sign up? Ignore this message. Without confirmation the account is not opened, and we will not write again.',
|
||||
'not_you' => 'Did not sign up? Ignore this message. Without confirmation the account is never activated, is removed by itself after five days, and we will not write again.',
|
||||
|
||||
// The page somebody lands on when signed in but not yet confirmed.
|
||||
'notice_title' => 'One step left',
|
||||
|
|
@ -19,6 +19,15 @@ return [
|
|||
'notice_hint' => 'Nothing arrived? Check the spam folder — or send it again.',
|
||||
'notice_resend' => 'Send again',
|
||||
'notice_sent' => 'Confirmation sent again.',
|
||||
'notice_signout' => 'Sign out',
|
||||
'notice_wrong_address' => 'Typed the wrong address? Sign out and register again.',
|
||||
'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',
|
||||
'delete_confirm' => 'Discard',
|
||||
'deleted' => 'Registration discarded. You can sign up again with the same address.',
|
||||
'delete_has_contract' => 'There is already a contract behind this account, so it cannot be discarded here. Write to us and we will sort it out.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
return [
|
||||
'card_title' => 'Right of withdrawal',
|
||||
'card_sub' => 'You may withdraw from this contract until :date — :days day(s) left. The service ends immediately and you get the full amount paid back.',
|
||||
'instead_title' => 'You can still withdraw',
|
||||
'instead_body' => 'Until :date you may withdraw from the contract instead: you get the whole amount back, but the cloud then ends at once rather than at the end of the paid period.',
|
||||
'cta' => 'Declare withdrawal',
|
||||
'keep' => 'Cancel',
|
||||
'confirm' => 'Declare withdrawal',
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@
|
|||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-ink">{{ $entry['label'] }}</h3>
|
||||
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['key'] }}</p>
|
||||
{{-- The NAME OF THE SERVER VARIABLE, not the internal registry key.
|
||||
|
||||
It used to print `stripe.secret` — an identifier from
|
||||
SecretVault::REGISTRY that means nothing on this page and is not
|
||||
something an operator can type anywhere. The env name is: it is
|
||||
what stands in the server file, it is what the .env tab of this
|
||||
same page lists, and when the badge beside this says "Aus der
|
||||
Serverdatei" it is the answer to "which line?". --}}
|
||||
<p class="mt-0.5 font-mono text-xs text-muted">{{ $entry['envKey'] }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{{-- Which of the two slots THIS card reads and writes right now —
|
||||
|
|
|
|||
|
|
@ -28,18 +28,37 @@
|
|||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f6f6f8;border-collapse:collapse;">
|
||||
<tr><td align="center" style="padding:32px 16px;">
|
||||
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
{{-- FLUID, with 600 as a ceiling rather than a floor. width="600" plus
|
||||
width:600px forced every phone to scroll sideways to read a sentence —
|
||||
reported exactly that way. A mail cannot rely on a media query (Outlook
|
||||
renders with Word and drops <style> blocks, which is why this file is
|
||||
inline attributes throughout), so the layout has to shrink by itself. --}}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
|
||||
<tr><td style="padding:0 0 20px 4px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding-right:10px;">
|
||||
{{-- The mark, not just its orange tile. It shipped as an empty
|
||||
square: the ascent inside it lives in an SVG the site loads, and
|
||||
a mail cannot — Gmail strips inline SVG, Outlook renders with
|
||||
Word, and this layout carries no image by design (half of all
|
||||
clients block remote content).
|
||||
|
||||
So the shape is drawn with a character every mail font has.
|
||||
▲ is the mark's own silhouette — the autopilot ascent — in white
|
||||
on the accent tile, which is the whole of what a reader
|
||||
recognises at 26 pixels. --}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td width="26" height="26" style="width:26px;height:26px;background-color:#f97316;border-radius:7px;font-size:0;line-height:0;"> </td></tr>
|
||||
<tr><td width="26" height="26" align="center" valign="middle" style="width:26px;height:26px;background-color:#f97316;border-radius:7px;text-align:center;">
|
||||
<span style="display:inline-block;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:26px;color:#ffffff;">▲</span>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="font-size:17px;line-height:26px;color:#17171c;letter-spacing:-0.2px;">
|
||||
<span style="font-weight:700;color:#17171c;">CluPilot</span>
|
||||
{{-- The whole name, as the wordmark writes it: CluPilot in ink,
|
||||
Cloud in the muted tone beside it. --}}
|
||||
<span style="font-weight:700;color:#17171c;">CluPilot</span> <span style="font-weight:600;color:#6e6e7a;">Cloud</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -48,7 +67,7 @@
|
|||
<tr><td style="background-color:#ffffff;border:1px solid #e9e9ee;border-radius:12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
|
||||
<tr><td style="padding:40px 40px 0 40px;">
|
||||
<tr><td style="padding:40px 24px 0 24px;">
|
||||
<h1 style="margin:0 0 14px 0;font-size:25px;line-height:32px;font-weight:700;color:#17171c;letter-spacing:-0.5px;">{{ $heading }}</h1>
|
||||
@if ($greeting)
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:24px;color:#43434e;">{{ $greeting }}</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
@props([
|
||||
'title' => null,
|
||||
'subtitle' => null,
|
||||
])
|
||||
|
||||
{{-- A settings panel: one card, a header that names it, a body of rows, and an
|
||||
optional footer for whatever finishes the group.
|
||||
|
||||
The page was a heap of cards of different heights, each holding one thing,
|
||||
which produced a staircase down the screen and left half the width empty
|
||||
beside it. A panel with divided rows is what every settings page worth
|
||||
copying does — the group is the card, the settings are its rows, and the
|
||||
rhythm comes from the dividers rather than from the gaps between boxes. --}}
|
||||
<section {{ $attributes->merge(['class' => 'overflow-hidden rounded-lg border border-line bg-surface shadow-xs']) }}>
|
||||
@if ($title || isset($header))
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-line px-6 py-4">
|
||||
@isset($header)
|
||||
{{ $header }}
|
||||
@else
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-sm font-bold text-ink">{{ $title }}</h2>
|
||||
@if ($subtitle)
|
||||
<p class="mt-0.5 max-w-[76ch] text-sm leading-relaxed text-muted">{{ $subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@isset($action)
|
||||
<div class="shrink-0">{{ $action }}</div>
|
||||
@endisset
|
||||
@endisset
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="divide-y divide-line">{{ $slot }}</div>
|
||||
|
||||
@isset($footer)
|
||||
<div class="flex flex-wrap items-center justify-end gap-3 border-t border-line bg-surface-2 px-6 py-4">
|
||||
{{ $footer }}
|
||||
</div>
|
||||
@endisset
|
||||
</section>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'for' => null,
|
||||
])
|
||||
|
||||
{{-- One row of a settings panel: what it is on the left, the control on the
|
||||
right.
|
||||
|
||||
The two-column shape is what uses the width. A single column of full-width
|
||||
inputs on a 1240px shell leaves two thirds of the line empty and makes a
|
||||
four-field form look like a questionnaire; a label column of 280px with the
|
||||
control beside it reads as a settings page and fits the screen it is on.
|
||||
|
||||
Stacks below `sm`, because on a telephone the label belongs above its
|
||||
field. --}}
|
||||
<div {{ $attributes->merge(['class' => 'grid gap-x-6 gap-y-2 px-6 py-4 sm:grid-cols-[minmax(0,280px)_minmax(0,1fr)] sm:items-start']) }}>
|
||||
@if ($label)
|
||||
<div class="min-w-0">
|
||||
<label @if ($for) for="{{ $for }}" @endif class="text-sm font-medium text-ink">{{ $label }}</label>
|
||||
@if ($hint)
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-muted">{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">{{ $slot }}</div>
|
||||
</div>
|
||||
|
|
@ -511,11 +511,47 @@
|
|||
// stylesheet by reading these files as text, so a class name
|
||||
// assembled at render time is a class name that was never compiled.
|
||||
$planColumns = ['lg:grid-cols-1', 'lg:grid-cols-2', 'lg:grid-cols-3', 'lg:grid-cols-4'][min(count($plans), 4) - 1];
|
||||
$bestFreeMonths = (int) collect($plans)->max('free_months');
|
||||
@endphp
|
||||
|
||||
{{-- Monthly or yearly, on the sheet itself. Both figures are rendered
|
||||
and one is hidden, so switching costs no request — and a visitor who
|
||||
never touches it still reads the monthly price the portal will
|
||||
charge. The saving is named next to the switch: "jährlich" on its
|
||||
own gives nobody a reason to look. --}}
|
||||
<div x-data="{ term: 'monthly' }" class="mt-12">
|
||||
{{-- The saving above the switch, and always present: hidden on the
|
||||
yearly view it re-centred the row every time somebody changed
|
||||
their mind. One line, two wordings. --}}
|
||||
@if ($bestFreeMonths > 0)
|
||||
<p class="mb-3 text-center text-sm font-semibold text-success">
|
||||
<span x-show="term !== 'yearly'">{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} bei Jahreszahlung</span>
|
||||
<span x-show="term === 'yearly'" x-cloak>{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} — in jedem Jahrespreis enthalten</span>
|
||||
</p>
|
||||
@endif
|
||||
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||
{{-- text-bg on the active option, not a colour that does not
|
||||
exist: `text-on-ink` is not in the palette (the ink pairing
|
||||
is bg-ink/text-bg, as x-ui.button's ink variant has it), so
|
||||
the label kept its inherited grey and sat unreadable on the
|
||||
dark pill. And one radius scale nested inside another: the
|
||||
shell rounded-lg, the option rounded — the shell's radius
|
||||
less its own padding. --}}
|
||||
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs" role="group">
|
||||
@foreach (['monthly' => 'Monatlich', 'yearly' => 'Jährlich'] as $option => $label)
|
||||
<button type="button" x-on:click="term = '{{ $option }}'"
|
||||
class="rounded px-5 py-2 text-sm font-semibold transition-colors"
|
||||
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-bg shadow-xs' : 'text-muted hover:text-ink'">
|
||||
{{ $label }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stretched, not items-start: the cards carry different numbers of
|
||||
features, and four "anfragen" buttons at four different heights is
|
||||
the first thing the eye picks up on a price sheet. --}}
|
||||
<div class="mt-14 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
|
||||
<div class="mt-8 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
|
||||
@foreach ($plans as $i => $plan)
|
||||
<div @class([
|
||||
'rv flex flex-col rounded-xl border p-7',
|
||||
|
|
@ -536,6 +572,11 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
|
||||
@endif
|
||||
|
||||
{{-- The headline stays "per month" in both terms: a yearly
|
||||
total shown as one big number reads as five times dearer
|
||||
at a glance, and the amount actually taken is named on
|
||||
the line under it. --}}
|
||||
<div x-show="term === 'monthly'">
|
||||
<p class="mt-6 flex items-baseline gap-1.5">
|
||||
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
|
||||
<span class="text-sm text-muted">/Monat</span>
|
||||
|
|
@ -548,6 +589,27 @@
|
|||
<p class="mt-1.5 text-xs text-muted">
|
||||
inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- The yearly PRICE, not the monthly equivalent of it. Shown
|
||||
per month, the figure did not move at all when the switch
|
||||
was thrown on a package with no free months, and the
|
||||
sheet looked like it had ignored the click. What it works
|
||||
out to per month is worth knowing and is said under. --}}
|
||||
<div x-show="term === 'yearly'" x-cloak>
|
||||
<p class="mt-6 flex items-baseline gap-1.5">
|
||||
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price_yearly'] }}</span>
|
||||
<span class="text-sm text-muted">/Jahr</span>
|
||||
</p>
|
||||
<p class="mt-1.5 text-xs text-muted">
|
||||
entspricht {{ $plan['price_yearly_monthly'] }} pro Monat · inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_yearly_net'] }}
|
||||
</p>
|
||||
@if ($plan['free_months'] > 0)
|
||||
<p class="mt-1 text-xs font-semibold text-success-text">
|
||||
{{ $plan['free_months'] === 1 ? '1 Monat gratis' : $plan['free_months'].' Monate gratis' }} · statt {{ $plan['price_twelve_months'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Named, or not mentioned. "zzgl. einmaliger Einrichtung"
|
||||
without a figure told a visitor only that there is a
|
||||
cost — the worst of both. Zero on the Finance page
|
||||
|
|
@ -601,6 +663,7 @@
|
|||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>{{-- /x-data: the switch and the cards it switches share one scope --}}
|
||||
|
||||
{{-- The baseline, said once and said first. Four of these were rows of
|
||||
ticks running straight down every column of the table below, and
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
{{--
|
||||
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>
|
||||
|
||||
@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">
|
||||
|
|
@ -25,6 +30,7 @@
|
|||
Zurück zur Startseite
|
||||
</x-ui.button>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
</x-layouts.site>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
{{--
|
||||
Auftragsverarbeitungsvertrag (Art. 28 DSGVO).
|
||||
|
||||
Rendered to PDF by App\Services\Legal\DpaRenderer through TCPDF, so the
|
||||
markup is deliberately plain: headings, paragraphs, lists and simple tables.
|
||||
TCPDF's HTML support is not a browser's — no flexbox, no grid, no custom
|
||||
properties — and anything fancier silently renders as something else.
|
||||
|
||||
The company's own details come from CompanyProfile, so this document and the
|
||||
invoices cannot name the same company differently.
|
||||
|
||||
## What this text is and is not
|
||||
|
||||
It is a working agreement built from what this installation actually does:
|
||||
the sub-processors are the ones the software really talks to, the measures
|
||||
referenced are the ones in the annex, and the deletion deadlines are the ones
|
||||
the code enforces. It is NOT a lawyer's opinion, and it should be read by one
|
||||
before it is relied on in a dispute — particularly the liability clause and
|
||||
anything a customer negotiates.
|
||||
--}}
|
||||
@php
|
||||
$c = App\Support\CompanyProfile::all();
|
||||
$seat = trim(($c['postcode'] ?? '').' '.($c['city'] ?? ''));
|
||||
|
||||
// A register number of "FN 000000a" is a placeholder somebody has not
|
||||
// replaced yet, and printing it on a contract is worse than leaving the line
|
||||
// out: it looks like a real number and is not one. Anything whose digits are
|
||||
// all zeros is treated as unfilled.
|
||||
$real = fn (?string $value) => $value !== null && $value !== '' && preg_match('/[1-9]/', $value) === 1;
|
||||
@endphp
|
||||
|
||||
<h1>Vertrag über die Auftragsverarbeitung</h1>
|
||||
<p><i>gemäß Art. 28 der Verordnung (EU) 2016/679 (DSGVO)</i></p>
|
||||
|
||||
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }}</p>
|
||||
|
||||
<h2>Die Vertragsparteien</h2>
|
||||
|
||||
<p><b>Auftragsverarbeiter</b> (im Folgenden „CluPilot"):<br />
|
||||
{{ $c['name'] ?? 'CluPilot Cloud e.U.' }}<br />
|
||||
{{ $c['address'] ?? '' }}<br />
|
||||
{{ $seat }}, {{ $c['country'] ?? 'Österreich' }}<br />
|
||||
@if ($real($c['register_number'] ?? null)){{ $c['register_number'] }}, {{ $c['register_court'] ?? '' }}<br />@endif
|
||||
@if ($real($c['vat_id'] ?? null)){{ $c['vat_id'] }}<br />@endif
|
||||
{{ $c['email'] ?? '' }}</p>
|
||||
|
||||
<p><b>Verantwortlicher</b> (im Folgenden „Kunde"):<br />
|
||||
die im Kundenkonto hinterlegten Stammdaten. Der Kunde hält diese Angaben aktuell; sie
|
||||
sind Bestandteil dieses Vertrags.</p>
|
||||
|
||||
<p>Dieser Vertrag wird elektronisch geschlossen. Art. 28 Abs. 9 DSGVO lässt das
|
||||
ausdrücklich zu; eine Unterschrift auf Papier ist nicht erforderlich. CluPilot hält
|
||||
Fassung, Zeitpunkt und IP-Adresse des Abschlusses fest und stellt dem Kunden den
|
||||
Vertrag im Kundenbereich dauerhaft zum Abruf bereit.</p>
|
||||
|
||||
<h2>1. Gegenstand und Dauer</h2>
|
||||
|
||||
<p>1.1 CluPilot betreibt für den Kunden eine eigene Nextcloud-Instanz und verarbeitet
|
||||
dabei personenbezogene Daten ausschließlich im Auftrag und nach Weisung des Kunden.
|
||||
Der Kunde bleibt Verantwortlicher im Sinne von Art. 4 Z 7 DSGVO.</p>
|
||||
|
||||
<p>1.2 Dieser Vertrag gilt für die Dauer des Hauptvertrags (Nutzung eines CluPilot-Pakets)
|
||||
und endet mit ihm. Regelungen, die ihrer Natur nach fortwirken — insbesondere
|
||||
Verschwiegenheit und Löschung —, gelten darüber hinaus.</p>
|
||||
|
||||
<h2>2. Art, Zweck und Umfang der Verarbeitung</h2>
|
||||
|
||||
<p>2.1 <b>Zweck:</b> Bereitstellung, Betrieb, Wartung und Sicherung einer
|
||||
Nextcloud-Installation samt zugehöriger Dienste (Speicherung und Freigabe von Dateien,
|
||||
Kalender, Kontakte, sowie die vom Kunden gebuchten Zusatzmodule).</p>
|
||||
|
||||
<p>2.2 <b>Art der Verarbeitung:</b> Speichern, Aufbewahren, Sichern, Übermitteln im
|
||||
Rahmen des Dienstes, Löschen. Eine inhaltliche Auswertung der Kundendaten findet nicht
|
||||
statt.</p>
|
||||
|
||||
<p>2.3 <b>Kategorien betroffener Personen:</b> Beschäftigte, Kundinnen und Kunden,
|
||||
Mandantinnen und Mandanten, Patientinnen und Patienten, Lieferanten und sonstige
|
||||
Kontakte des Kunden — je nachdem, wessen Daten der Kunde in seiner Instanz speichert.</p>
|
||||
|
||||
<p>2.4 <b>Kategorien personenbezogener Daten:</b> Stamm- und Kontaktdaten,
|
||||
Kommunikationsdaten, Inhalts- und Dokumentendaten, Nutzungs- und Protokolldaten der
|
||||
Instanz. Der Kunde entscheidet allein, welche Daten er speichert; besondere Kategorien
|
||||
nach Art. 9 DSGVO sind möglich, wenn der Kunde solche Daten einbringt.</p>
|
||||
|
||||
<h2>3. Weisungen</h2>
|
||||
|
||||
<p>3.1 CluPilot verarbeitet die Daten nur auf dokumentierte Weisung des Kunden. Der
|
||||
Hauptvertrag samt Leistungsbeschreibung ist die vollständige Weisung für den
|
||||
Regelbetrieb; weitere Weisungen erteilt der Kunde in Textform an
|
||||
{{ $c['email'] ?? '' }} oder über den Kundenbereich.</p>
|
||||
|
||||
<p>3.2 Hält CluPilot eine Weisung für rechtswidrig, teilt sie dies dem Kunden unverzüglich
|
||||
mit und darf die Ausführung bis zur Bestätigung aussetzen (Art. 28 Abs. 3 letzter Satz
|
||||
DSGVO).</p>
|
||||
|
||||
<p>3.3 Eine Verarbeitung zu eigenen Zwecken findet nicht statt. Davon unberührt bleiben
|
||||
Daten, die CluPilot als eigener Verantwortlicher verarbeitet — insbesondere die
|
||||
Vertrags- und Rechnungsdaten des Kunden selbst.</p>
|
||||
|
||||
<h2>4. Vertraulichkeit</h2>
|
||||
|
||||
<p>CluPilot setzt zur Verarbeitung nur Personen ein, die zur Vertraulichkeit verpflichtet
|
||||
und über die einschlägigen Bestimmungen des Datenschutzes unterwiesen sind (Art. 28
|
||||
Abs. 3 lit. b DSGVO). Die Verpflichtung wirkt über das Ende der Tätigkeit hinaus.</p>
|
||||
|
||||
<h2>5. Technische und organisatorische Maßnahmen</h2>
|
||||
|
||||
<p>5.1 CluPilot trifft die Maßnahmen nach Art. 32 DSGVO. Sie sind in der <b>Anlage 1
|
||||
(TOM)</b> beschrieben, die Bestandteil dieses Vertrags ist.</p>
|
||||
|
||||
<p>5.2 Die Maßnahmen unterliegen dem technischen Fortschritt. CluPilot darf sie
|
||||
weiterentwickeln, solange das Schutzniveau nicht unterschritten wird; wesentliche
|
||||
Änderungen werden dem Kunden mit einer neuen Fassung dieses Vertrags mitgeteilt.</p>
|
||||
|
||||
<h2>6. Unterauftragsverarbeiter</h2>
|
||||
|
||||
<p>6.1 Der Kunde erteilt die allgemeine Genehmigung zur Beauftragung der nachstehenden
|
||||
Unterauftragsverarbeiter (Art. 28 Abs. 4 DSGVO):</p>
|
||||
|
||||
<table border="1" cellpadding="4">
|
||||
<tr>
|
||||
<td width="30%"><b>Unternehmen</b></td>
|
||||
<td width="30%"><b>Leistung</b></td>
|
||||
<td width="40%"><b>Ort der Verarbeitung</b></td>
|
||||
</tr>
|
||||
@foreach ($subprocessors as $sub)
|
||||
<tr>
|
||||
<td>{{ $sub['name'] }}</td>
|
||||
<td>{{ $sub['service'] }}</td>
|
||||
<td>{{ $sub['location'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</table>
|
||||
|
||||
<p>6.2 CluPilot informiert den Kunden über die Hinzuziehung oder den Austausch eines
|
||||
Unterauftragsverarbeiters mindestens vier Wochen vorher in Textform. Der Kunde kann
|
||||
binnen zwei Wochen ab Zugang aus wichtigem, datenschutzbezogenem Grund widersprechen;
|
||||
in diesem Fall kann jede Seite den Vertrag zum Wirksamwerden der Änderung kündigen.</p>
|
||||
|
||||
<p>6.3 CluPilot verpflichtet jeden Unterauftragsverarbeiter auf Pflichten, die diesem
|
||||
Vertrag entsprechen, und haftet für dessen Verhalten wie für eigenes.</p>
|
||||
|
||||
<h2>7. Ort der Verarbeitung, Drittländer</h2>
|
||||
|
||||
<p>Die Verarbeitung findet ausschließlich in Mitgliedstaaten der Europäischen Union
|
||||
statt. Eine Übermittlung in ein Drittland erfolgt nicht; sollte sie künftig
|
||||
erforderlich werden, geschieht dies nur unter den Voraussetzungen der Art. 44 ff.
|
||||
DSGVO und nach vorheriger Information des Kunden.</p>
|
||||
|
||||
<h2>8. Unterstützung des Kunden</h2>
|
||||
|
||||
<p>8.1 CluPilot unterstützt den Kunden mit geeigneten Maßnahmen bei der Erfüllung von
|
||||
Betroffenenrechten (Art. 12 bis 23 DSGVO). Wendet sich eine betroffene Person direkt an
|
||||
CluPilot, wird sie an den Kunden verwiesen und der Kunde unverzüglich verständigt.</p>
|
||||
|
||||
<p>8.2 CluPilot unterstützt den Kunden bei den Pflichten nach Art. 32 bis 36 DSGVO,
|
||||
insbesondere bei Datenschutz-Folgenabschätzungen und bei Meldungen an die Aufsichtsbehörde.</p>
|
||||
|
||||
<p>8.3 <b>Verletzungen des Schutzes personenbezogener Daten</b> meldet CluPilot dem Kunden
|
||||
unverzüglich, spätestens innerhalb von 24 Stunden ab Kenntnis, mit den nach Art. 33
|
||||
Abs. 3 DSGVO erforderlichen Angaben, soweit sie vorliegen. Die Meldung an die
|
||||
Aufsichtsbehörde obliegt dem Kunden.</p>
|
||||
|
||||
<h2>9. Nachweise und Kontrollen</h2>
|
||||
|
||||
<p>9.1 CluPilot stellt dem Kunden auf Anfrage die zum Nachweis der Einhaltung dieses
|
||||
Vertrags erforderlichen Informationen zur Verfügung, insbesondere die jeweils geltende
|
||||
Fassung der Anlage 1.</p>
|
||||
|
||||
<p>9.2 Der Kunde ist berechtigt, sich von der Einhaltung zu überzeugen. Kontrollen finden
|
||||
nach Anmeldung mit angemessener Frist, während der üblichen Geschäftszeiten und ohne
|
||||
Störung des Betriebsablaufs statt. CluPilot kann eine Kontrolle durch Vorlage geeigneter
|
||||
Nachweise ersetzen, soweit dies zur Überprüfung ausreicht. Der Zutritt zu Räumen Dritter
|
||||
richtet sich nach den Regeln des jeweiligen Unterauftragsverarbeiters.</p>
|
||||
|
||||
<h2>10. Löschung und Rückgabe</h2>
|
||||
|
||||
<p>10.1 Nach Beendigung des Hauptvertrags stellt CluPilot dem Kunden dessen Daten in
|
||||
einem gängigen Format zum Abruf bereit. Die Bereitstellung erfolgt zum Ende der bezahlten
|
||||
Periode; der Kunde hat ab Bereitstellung <b>30 Tage</b> Zeit, sie abzuholen.</p>
|
||||
|
||||
<p>10.2 Nach Ablauf dieser Frist löscht CluPilot die Instanz samt aller darin enthaltenen
|
||||
personenbezogenen Daten sowie die zugehörigen Sicherungen. Die Löschung wird dem Kunden
|
||||
auf Anfrage bestätigt.</p>
|
||||
|
||||
<p>10.3 Von der Löschung ausgenommen sind Daten, für die eine gesetzliche
|
||||
Aufbewahrungspflicht besteht — insbesondere Rechnungen und Buchhaltungsunterlagen
|
||||
(sieben Jahre, § 132 BAO). Diese Daten verarbeitet CluPilot als eigener Verantwortlicher.</p>
|
||||
|
||||
<h2>11. Haftung</h2>
|
||||
|
||||
<p>Es gilt Art. 82 DSGVO. Im Übrigen richtet sich die Haftung nach dem Hauptvertrag.</p>
|
||||
|
||||
<h2>12. Schlussbestimmungen</h2>
|
||||
|
||||
<p>12.1 Änderungen bedürfen der Textform. Widerspricht dieser Vertrag dem Hauptvertrag in
|
||||
Fragen des Datenschutzes, geht dieser Vertrag vor.</p>
|
||||
|
||||
<p>12.2 Ist eine Bestimmung unwirksam, bleibt der übrige Vertrag gültig.</p>
|
||||
|
||||
<p>12.3 Es gilt österreichisches Recht. Gerichtsstand ist, soweit gesetzlich zulässig,
|
||||
{{ $c['city'] ?? 'Wien' }}.</p>
|
||||
|
||||
<p><b>Anlage 1:</b> Technische und organisatorische Maßnahmen (TOM), Fassung {{ $version }}.</p>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
{{--
|
||||
Anlage 1: Technische und organisatorische Maßnahmen (Art. 32 DSGVO).
|
||||
|
||||
Written from what this installation ACTUALLY does — the backup job as
|
||||
RegisterBackup creates it, the isolation Proxmox gives, the sign-in as
|
||||
Fortify enforces it, the deletion deadlines the two prune commands run. Every
|
||||
sentence here is meant to survive somebody checking it against the machine.
|
||||
|
||||
Where a measure is planned but not in place, it is not written down. A TOM
|
||||
that claims more than the system does is worse than a short one: it is the
|
||||
document an auditor reads before looking.
|
||||
--}}
|
||||
@php
|
||||
$c = App\Support\CompanyProfile::all();
|
||||
@endphp
|
||||
|
||||
<h1>Anlage 1 — Technische und organisatorische Maßnahmen</h1>
|
||||
<p><i>gemäß Art. 32 DSGVO, Anlage zum Auftragsverarbeitungsvertrag</i></p>
|
||||
|
||||
<p><b>Fassung {{ $version }}</b> · Stand {{ $issuedOn }} · {{ $c['name'] ?? 'CluPilot Cloud e.U.' }}</p>
|
||||
|
||||
<p>Diese Anlage beschreibt die Maßnahmen, die CluPilot beim Betrieb der
|
||||
Kundeninstanzen tatsächlich umsetzt. Sie wird mit jeder Fassung des
|
||||
Auftragsverarbeitungsvertrags überprüft und, wo nötig, fortgeschrieben.</p>
|
||||
|
||||
<h2>1. Zutrittskontrolle (physisch)</h2>
|
||||
|
||||
<p>Die Instanzen laufen ausschließlich in Rechenzentren der in Ziffer 6 des
|
||||
Hauptvertrags genannten Unterauftragsverarbeiter innerhalb der Europäischen Union.
|
||||
Physischer Zutritt, Videoüberwachung, Zutrittsprotokollierung, Brand- und
|
||||
Einbruchschutz sowie unterbrechungsfreie Stromversorgung liegen in deren
|
||||
Verantwortung und richten sich nach deren jeweils veröffentlichten Nachweisen
|
||||
(u. a. ISO/IEC 27001). CluPilot selbst betreibt keine eigenen Serverräume.</p>
|
||||
|
||||
<h2>2. Zugangskontrolle (Systemzugang)</h2>
|
||||
|
||||
<ul>
|
||||
<li>Jeder Zugang zum Kundenportal und zur Betreiberkonsole erfordert Benutzername
|
||||
und Passwort. Passwörter werden ausschließlich als Hash gespeichert (bcrypt);
|
||||
die Klartexte sind CluPilot zu keinem Zeitpunkt bekannt.</li>
|
||||
<li>Zwei-Faktor-Authentisierung (TOTP) steht sowohl Kundinnen und Kunden als auch
|
||||
Betreibern zur Verfügung; für Betreiberkonten wird sie eingesetzt.</li>
|
||||
<li>Sicherheitsrelevante Änderungen (Zwei-Faktor abschalten, Schlüssel speichern)
|
||||
verlangen zusätzlich eine erneute Passwortbestätigung innerhalb der laufenden
|
||||
Sitzung.</li>
|
||||
<li>Angemeldete Geräte werden je Konto geführt und können einzeln abgemeldet
|
||||
werden; eine Anmeldung von einem neuen Gerät löst eine Benachrichtigung per
|
||||
E-Mail aus.</li>
|
||||
<li>Die Betreiberkonsole ist über eine eigene Adresse erreichbar und für das
|
||||
öffentliche Internet gesperrt; der Zugang erfolgt über ein privates Netz
|
||||
(WireGuard).</li>
|
||||
<li>Der administrative Zugriff auf die Virtualisierungsplattform erfolgt über
|
||||
API-Token je Host, nicht über gemeinsam genutzte Kennwörter.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Zugriffskontrolle (Berechtigungen)</h2>
|
||||
|
||||
<ul>
|
||||
<li>Die Betreiberkonsole kennt abgestufte Rollen (Owner, Admin, Support, Billing,
|
||||
Read-only) mit einzeln vergebenen Berechtigungen; jede Aktion prüft die
|
||||
Berechtigung serverseitig, nicht durch Ausblenden von Bedienelementen.</li>
|
||||
<li>Betreiber- und Kundenkonten sind getrennte Identitäten in getrennten Tabellen
|
||||
mit getrennten Anmeldewegen. Ein Kundenkonto kann keine Betreiberrechte
|
||||
erhalten.</li>
|
||||
<li>CluPilot greift nicht auf Inhalte in Kundeninstanzen zu. Ein Zugriff erfolgt
|
||||
nur, wenn der Kunde im Rahmen einer Supportanfrage darum ersucht, oder wenn
|
||||
eine gesetzliche Verpflichtung besteht.</li>
|
||||
<li>Zugangsdaten und Schlüssel (Zahlungsdienst, DNS, Hosts, Mailkonten) werden
|
||||
verschlüsselt gespeichert; der dafür verwendete Schlüssel ist vom
|
||||
Anwendungsschlüssel getrennt.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Trennungskontrolle (Mandantentrennung)</h2>
|
||||
|
||||
<ul>
|
||||
<li>Jede Kundin und jeder Kunde erhält eine <b>eigene virtuelle Maschine</b> mit
|
||||
eigenem Betriebssystem, eigener Datenbank und eigenem Dateisystem. Es gibt
|
||||
keine geteilte Nextcloud-Installation und keine gemeinsame Datenbank für
|
||||
Kundeninhalte.</li>
|
||||
<li>Jede Instanz hat eine eigene Adresse und ein eigenes Zertifikat.</li>
|
||||
<li>Die Verwaltungsdaten von CluPilot (Verträge, Rechnungen, Betriebsdaten) sind
|
||||
von den Kundeninhalten getrennt und liegen nicht in den Kundeninstanzen.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Weitergabekontrolle (Transport und Ablage)</h2>
|
||||
|
||||
<ul>
|
||||
<li>Sämtlicher Verkehr zu Kundeninstanzen, Portal und Konsole läuft über HTTPS mit
|
||||
Zertifikaten von Let's Encrypt; unverschlüsselte Verbindungen werden nicht
|
||||
angeboten.</li>
|
||||
<li>E-Mail wird über TLS an den Mailserver übergeben.</li>
|
||||
<li>Administrativer Zugriff auf Hosts erfolgt über SSH beziehungsweise über die
|
||||
API der Virtualisierungsplattform, jeweils verschlüsselt.</li>
|
||||
<li>Zahlungsdaten werden nicht von CluPilot verarbeitet: Karteneingabe und
|
||||
Zahlungsabwicklung finden beim Zahlungsdienstleister statt. CluPilot erfährt
|
||||
nur, dass und in welcher Höhe gezahlt wurde.</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Eingabekontrolle (Nachvollziehbarkeit)</h2>
|
||||
|
||||
<ul>
|
||||
<li>Jede automatisierte Einrichtung, Änderung und Beendigung einer Instanz wird
|
||||
Schritt für Schritt protokolliert und bleibt in der Konsole einsehbar.</li>
|
||||
<li>Ausgehende E-Mails an Kundinnen und Kunden werden mit Zeitpunkt, Empfänger und
|
||||
Betreff in einem eigenen Register geführt.</li>
|
||||
<li>Wesentliche Vertragsvorgänge (Bestellung, Kündigung samt Grund, Widerruf,
|
||||
Abschluss dieses Vertrags) werden mit Zeitpunkt festgehalten; der Abschluss
|
||||
dieser Vereinbarung zusätzlich mit Fassung und IP-Adresse.</li>
|
||||
<li>Anwendungs- und Systemprotokolle werden auf den Servern geführt.</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Verfügbarkeit und Belastbarkeit</h2>
|
||||
|
||||
<ul>
|
||||
<li><b>Sicherung:</b> Für jede Instanz wird bei der Einrichtung ein täglicher
|
||||
Sicherungslauf um 02:00 Uhr auf der Virtualisierungsplattform eingerichtet
|
||||
(Snapshot der gesamten Maschine).</li>
|
||||
<li><b>Überwachung:</b> Erreichbarkeit und Zustand der Instanzen und Hosts werden
|
||||
laufend überwacht; Störungen erzeugen Meldungen an den Betrieb.</li>
|
||||
<li><b>Kapazität:</b> Vor jeder Einrichtung wird geprüft, ob auf dem Zielhost
|
||||
ausreichend Speicher und Rechenleistung frei sind; reicht sie nicht, wird die
|
||||
Bestellung vorgemerkt statt einen Host zu überbuchen.</li>
|
||||
<li><b>Aktualisierung:</b> Sicherheits- und Versionsaktualisierungen werden
|
||||
eingespielt; geplante Wartungen werden angekündigt.</li>
|
||||
</ul>
|
||||
|
||||
<h2>8. Löschung und Aufbewahrung</h2>
|
||||
|
||||
<ul>
|
||||
<li>Nach Vertragsende werden die Daten zum Abruf bereitgestellt und nach Ablauf der
|
||||
im Hauptvertrag genannten Frist samt Sicherungen gelöscht.</li>
|
||||
<li>Registrierungen, deren E-Mail-Adresse nie bestätigt wurde, werden nach
|
||||
{{ $unverifiedDays }} Tagen automatisch gelöscht.</li>
|
||||
<li>Bestätigte Konten ohne jemals gebuchtes Paket werden nach einem Jahr gelöscht;
|
||||
die Löschung wird {{ $warnDays }} Tage vorher per E-Mail angekündigt.</li>
|
||||
<li>Rechnungen und Buchhaltungsunterlagen werden sieben Jahre aufbewahrt
|
||||
(§ 132 BAO). Sie enthalten Vertrags-, nicht Inhaltsdaten.</li>
|
||||
</ul>
|
||||
|
||||
<h2>9. Auftragskontrolle und Überprüfung</h2>
|
||||
|
||||
<ul>
|
||||
<li>Unterauftragsverarbeiter werden vertraglich auf ein entsprechendes
|
||||
Schutzniveau verpflichtet; die Liste ist Teil des Hauptvertrags.</li>
|
||||
<li>Diese Maßnahmen werden bei jeder neuen Fassung des
|
||||
Auftragsverarbeitungsvertrags überprüft, mindestens jedoch jährlich.</li>
|
||||
<li>Ansprechstelle für Datenschutzfragen: {{ $c['email'] ?? '' }}</li>
|
||||
</ul>
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
{{--
|
||||
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 der Vertrag über die
|
||||
Auftragsverarbeitung nach Art. 28 DSGVO in seiner jeweils geltenden Fassung. Er wird mit
|
||||
diesen AGB Bestandteil des Vertrags; einer gesonderten Unterzeichnung bedarf es nicht.
|
||||
Der Vertrag und die Dokumentation der technischen und organisatorischen Maßnahmen stehen
|
||||
im Kundenbereich jederzeit zum Ansehen und Herunterladen bereit. Eine neue Fassung wird
|
||||
dem Kunden mitgeteilt.
|
||||
</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>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
|
||||
<x-ui.icon name="alert-triangle" class="size-5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('dpa_admin.publish_title', ['version' => $version]) }}</h3>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('dpa_admin.publish_body') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">{{ __('dpa_admin.publish') }}</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -42,10 +42,15 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label for="billingAddress" class="block text-sm font-medium text-body">{{ __('edit_customer.address') }}</label>
|
||||
<textarea id="billingAddress" rows="4" wire:model="billingAddress"
|
||||
class="mt-1.5 block w-full rounded border border-line bg-surface px-3.5 py-2.5 text-sm text-ink"></textarea>
|
||||
@error('billingAddress')<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
<p class="block text-sm font-medium text-body">{{ __('edit_customer.address') }}</p>
|
||||
<div class="mt-1.5 space-y-3">
|
||||
<x-ui.input name="billingStreet" wire:model="billingStreet" :label="__('settings.billing_street')" />
|
||||
<div class="grid gap-3 sm:grid-cols-[140px_minmax(0,1fr)]">
|
||||
<x-ui.input name="billingPostcode" wire:model="billingPostcode" :label="__('settings.billing_postcode')" />
|
||||
<x-ui.input name="billingCity" wire:model="billingCity" :label="__('settings.billing_city')" />
|
||||
</div>
|
||||
<x-ui.input name="billingCountry" wire:model="billingCountry" :label="__('settings.billing_country')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-56">
|
||||
|
|
|
|||
|
|
@ -24,6 +24,31 @@
|
|||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
{{-- When the mailbox was last reached. Shown whether or not anything came of
|
||||
it: an empty inbox and a mailbox that stopped answering look exactly the
|
||||
same, and the difference is this line. --}}
|
||||
@if ($configured)
|
||||
<p @class([
|
||||
'flex flex-wrap items-center gap-1.5 text-xs',
|
||||
'text-muted' => $status === null || $status['ok'],
|
||||
'text-danger' => $status !== null && ! $status['ok'],
|
||||
])>
|
||||
@if ($status === null)
|
||||
{{ __('inbox.never_checked') }}
|
||||
@else
|
||||
<x-ui.icon :name="$status['ok'] ? 'check' : 'alert-triangle'" class="size-3.5" />
|
||||
{{-- R19: stored in UTC, read on the wall clock. --}}
|
||||
{{ __($status['ok'] ? 'inbox.last_ok' : 'inbox.last_failed', [
|
||||
'when' => $status['at']->local()->isoFormat('D. MMM YYYY, HH:mm'),
|
||||
'ago' => $status['at']->diffForHumans(),
|
||||
]) }}
|
||||
@if (! $status['ok'])
|
||||
<a href="{{ route('admin.integrations') }}" class="font-semibold underline">{{ __('inbox.to_integrations') }}</a>
|
||||
@endif
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($mails->isEmpty())
|
||||
<div class="rounded-lg border border-line bg-surface p-10 text-center shadow-xs animate-rise">
|
||||
<p class="text-sm text-muted">{{ $archived ? __('inbox.empty_archived') : __('inbox.empty') }}</p>
|
||||
|
|
|
|||
|
|
@ -155,6 +155,35 @@
|
|||
@if ($canInfra)<hr class="border-line" />@endif
|
||||
<x-admin.secret-field :entry="$entries['inbound_mail.password']" :unlocked="$unlocked" :mode="$mode" />
|
||||
@endif
|
||||
|
||||
{{-- The one thing an operator wants after typing all of it: did any
|
||||
of it work. The button saves first, because testing what is on
|
||||
screen while the server still holds the old values would report
|
||||
on a mailbox nobody configured. --}}
|
||||
@if ($canInfra)
|
||||
<div class="flex flex-wrap items-center gap-3 border-t border-line pt-4">
|
||||
<x-ui.button wire:click="testInbound" variant="secondary" size="sm"
|
||||
wire:loading.attr="disabled" wire:target="testInbound">
|
||||
<x-ui.icon name="plug" class="size-4" />{{ __('integrations.inbound_test') }}
|
||||
</x-ui.button>
|
||||
|
||||
{{-- Always said, including "never": a line that appears only
|
||||
after a success leaves an operator unable to tell a
|
||||
mailbox that has not been checked from one that is fine.
|
||||
R19: stored in UTC, read on the wall clock. --}}
|
||||
@if ($inboundStatus === null)
|
||||
<p class="text-xs text-muted">{{ __('integrations.inbound_never') }}</p>
|
||||
@else
|
||||
<p @class(['flex items-center gap-1.5 text-xs', 'text-success' => $inboundStatus['ok'], 'text-danger' => ! $inboundStatus['ok']])>
|
||||
<x-ui.icon :name="$inboundStatus['ok'] ? 'check' : 'alert-triangle'" class="size-3.5" />
|
||||
{{ __($inboundStatus['ok'] ? 'integrations.inbound_last_ok' : 'integrations.inbound_last_failed', [
|
||||
'when' => $inboundStatus['at']->local()->isoFormat('D. MMM YYYY, HH:mm'),
|
||||
'ago' => $inboundStatus['at']->diffForHumans(),
|
||||
]) }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<div class="mx-auto max-w-[1120px] space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_preview.title') }}</h1>
|
||||
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('mail_preview.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- A preview that goes nowhere is worse than none: the browser view still
|
||||
works, and "senden" would write to a file and report success. --}}
|
||||
@if (! $delivers)
|
||||
<x-ui.alert variant="warning">
|
||||
{{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }}
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<ul class="divide-y divide-line">
|
||||
@foreach ($previews as $key => $label)
|
||||
<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>
|
||||
{{-- 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
|
||||
its own background, and judging it inside a console page
|
||||
judges the console's frame around it. --}}
|
||||
<x-ui.button :href="route('admin.mail.preview.show', $key)" variant="secondary" size="sm" target="_blank">
|
||||
<x-ui.icon name="external-link" class="size-4" />{{ __('mail_preview.open') }}
|
||||
</x-ui.button>
|
||||
|
||||
<x-ui.button wire:click="sendToMe('{{ $key }}')" variant="primary" size="sm"
|
||||
wire:loading.attr="disabled" wire:target="sendToMe('{{ $key }}')"
|
||||
:disabled="! $delivers">
|
||||
<x-ui.icon name="send" class="size-4" />{{ __('mail_preview.send') }}
|
||||
</x-ui.button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="text-xs leading-relaxed text-muted">{{ __('mail_preview.note', ['email' => $me]) }}</p>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue