diff --git a/VERSION b/VERSION index 155d478..02c1916 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.44 +1.3.62 diff --git a/app/Console/Commands/PruneDormantAccounts.php b/app/Console/Commands/PruneDormantAccounts.php new file mode 100644 index 0000000..4a5362c --- /dev/null +++ b/app/Console/Commands/PruneDormantAccounts.php @@ -0,0 +1,206 @@ +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 + */ + 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); + } +} diff --git a/app/Console/Commands/PruneUnverifiedAccounts.php b/app/Console/Commands/PruneUnverifiedAccounts.php new file mode 100644 index 0000000..d9513c3 --- /dev/null +++ b/app/Console/Commands/PruneUnverifiedAccounts.php @@ -0,0 +1,82 @@ +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; + } +} diff --git a/app/Console/Commands/PublishProcessingAgreement.php b/app/Console/Commands/PublishProcessingAgreement.php new file mode 100644 index 0000000..4a97309 --- /dev/null +++ b/app/Console/Commands/PublishProcessingAgreement.php @@ -0,0 +1,85 @@ +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'; + } +} diff --git a/app/Console/Commands/SyncStripeCatalogue.php b/app/Console/Commands/SyncStripeCatalogue.php index d7ba574..f33270b 100644 --- a/app/Console/Commands/SyncStripeCatalogue.php +++ b/app/Console/Commands/SyncStripeCatalogue.php @@ -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; } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 38c1b68..3193012 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -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 diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 5e0af28..6bcd6d2 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -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'], diff --git a/app/Livewire/Admin/ConfirmPublishDpa.php b/app/Livewire/Admin/ConfirmPublishDpa.php new file mode 100644 index 0000000..64ce619 --- /dev/null +++ b/app/Livewire/Admin/ConfirmPublishDpa.php @@ -0,0 +1,45 @@ +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'); + } +} diff --git a/app/Livewire/Admin/EditCustomer.php b/app/Livewire/Admin/EditCustomer.php index 06e5b47..76ad9e8 100644 --- a/app/Livewire/Admin/EditCustomer.php +++ b/app/Livewire/Admin/EditCustomer.php @@ -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, ]); diff --git a/app/Livewire/Admin/Inbox.php b/app/Livewire/Admin/Inbox.php index 0204def..f6443f4 100644 --- a/app/Livewire/Admin/Inbox.php +++ b/app/Livewire/Admin/Inbox.php @@ -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(), ]); } } diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index a5ab5b3..6c314d2 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -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, diff --git a/app/Livewire/Admin/MailPreview.php b/app/Livewire/Admin/MailPreview.php new file mode 100644 index 0000000..e4e6a3d --- /dev/null +++ b/app/Livewire/Admin/MailPreview.php @@ -0,0 +1,106 @@ +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), + ]); + } +} diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index 30fd08e..4ebc4df 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -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 diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index f86d0f9..0b9611b 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -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), ]); } } diff --git a/app/Livewire/Admin/ProcessingAgreements.php b/app/Livewire/Admin/ProcessingAgreements.php new file mode 100644 index 0000000..6a35497 --- /dev/null +++ b/app/Livewire/Admin/ProcessingAgreements.php @@ -0,0 +1,137 @@ +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); + } +} diff --git a/app/Livewire/Auth/VerifyEmail.php b/app/Livewire/Auth/VerifyEmail.php index ad621e4..fd936fb 100644 --- a/app/Livewire/Auth/VerifyEmail.php +++ b/app/Livewire/Auth/VerifyEmail.php @@ -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() diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 23c9b64..8b16e09 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -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(), diff --git a/app/Livewire/Checkout.php b/app/Livewire/Checkout.php new file mode 100644 index 0000000..5d053ad --- /dev/null +++ b/app/Livewire/Checkout.php @@ -0,0 +1,153 @@ +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|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 $plan + * @return array + */ + 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, + ]; + } +} diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index f0919e1..3d2a543 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -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, ]); } } diff --git a/app/Livewire/ConfirmDeleteOwnAccount.php b/app/Livewire/ConfirmDeleteOwnAccount.php new file mode 100644 index 0000000..e8de50e --- /dev/null +++ b/app/Livewire/ConfirmDeleteOwnAccount.php @@ -0,0 +1,28 @@ +dispatch('own-account-delete-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.confirm-delete-own-account'); + } +} diff --git a/app/Livewire/Order.php b/app/Livewire/Order.php index d8f309d..295c9cb 100644 --- a/app/Livewire/Order.php +++ b/app/Livewire/Order.php @@ -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'], diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 336216f..dd4c3eb 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -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 $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), ]); } } diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php new file mode 100644 index 0000000..9f5ea31 --- /dev/null +++ b/app/Mail/DormantAccountWarningMail.php @@ -0,0 +1,48 @@ +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'), + ]); + } +} diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index cc52021..bbe8d5d 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -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 { diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index ca0009b..5a3f23e 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -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 { diff --git a/app/Models/Customer.php b/app/Models/Customer.php index da61ee5..8c300c4 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -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', ]; diff --git a/app/Models/DpaAcceptance.php b/app/Models/DpaAcceptance.php new file mode 100644 index 0000000..b715121 --- /dev/null +++ b/app/Models/DpaAcceptance.php @@ -0,0 +1,42 @@ + '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); + } +} diff --git a/app/Models/DpaVersion.php b/app/Models/DpaVersion.php new file mode 100644 index 0000000..16bc583 --- /dev/null +++ b/app/Models/DpaVersion.php @@ -0,0 +1,60 @@ + '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'); + } +} diff --git a/app/Models/PlanVersion.php b/app/Models/PlanVersion.php index d554844..92b3fca 100644 --- a/app/Models/PlanVersion.php +++ b/app/Models/PlanVersion.php @@ -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, diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 0163a6c..e3765bd 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -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) { diff --git a/app/Models/User.php b/app/Models/User.php index 12c58a7..9ee8281 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 diff --git a/app/Services/Billing/AddonPrices.php b/app/Services/Billing/AddonPrices.php index 6973cdd..ecb49bd 100644 --- a/app/Services/Billing/AddonPrices.php +++ b/app/Services/Billing/AddonPrices.php @@ -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,30 +128,45 @@ final class AddonPrices $productId = $this->product($addonKey); - $priceId = $this->stripe->createPrice( + $metadata = [ + // Read back when a Stripe invoice line has to be turned into + // wording a customer can read — see StripeInvoiceLines. + 'addon' => $addonKey, + // Which of the module's two Prices this is, for anyone reading + // Stripe's own dashboard, where they would otherwise differ only + // by an amount. + 'tax_treatment' => $reverseCharge ? 'reverse_charge' : 'domestic', + ]; + + // 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: [ - // Read back when a Stripe invoice line has to be turned into - // wording a customer can read — see StripeInvoiceLines. - 'addon' => $addonKey, - // Which of the module's two Prices this is, for anyone reading - // 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. + 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. } } } diff --git a/app/Services/Billing/AdoptStripePrice.php b/app/Services/Billing/AdoptStripePrice.php new file mode 100644 index 0000000..41461d5 --- /dev/null +++ b/app/Services/Billing/AdoptStripePrice.php @@ -0,0 +1,188 @@ + $metadata what the create call would send + * @param array $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 $found + * @param array $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 $found + * @param array $expected + * @param array $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; + } +} diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 4392ace..2d2080e 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -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 diff --git a/app/Services/Billing/PlanPrices.php b/app/Services/Billing/PlanPrices.php index ed8d631..5d58ce7 100644 --- a/app/Services/Billing/PlanPrices.php +++ b/app/Services/Billing/PlanPrices.php @@ -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,30 +134,56 @@ 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( + $metadata = [ + 'plan_family' => $family->key, + 'plan_version' => (string) $version->version, + 'plan_version_id' => (string) $version->id, + 'plan_price_id' => (string) $price->id, + // Read back by anything that has a Price id and needs to know + // what kind of sale it was — and by a person looking at + // Stripe's own dashboard, where two Prices on one Product + // would otherwise differ only by an amount. + 'tax_treatment' => $treatment->reverseCharge ? 'reverse_charge' : 'domestic', + ]; + + // 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: $price->term === Subscription::TERM_YEARLY ? 'year' : 'month', - metadata: [ - 'plan_family' => $family->key, - 'plan_version' => (string) $version->version, - 'plan_version_id' => (string) $version->id, - 'plan_price_id' => (string) $price->id, - // Read back by anything that has a Price id and needs to know - // what kind of sale it was — and by a person looking at - // 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. + 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' : ''), ); diff --git a/app/Services/Legal/DpaRenderer.php b/app/Services/Legal/DpaRenderer.php new file mode 100644 index 0000000..2567f50 --- /dev/null +++ b/app/Services/Legal/DpaRenderer.php @@ -0,0 +1,109 @@ +> + */ + 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 $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'); + } +} diff --git a/app/Services/Legal/ProcessingAgreement.php b/app/Services/Legal/ProcessingAgreement.php new file mode 100644 index 0000000..b8e8e68 --- /dev/null +++ b/app/Services/Legal/ProcessingAgreement.php @@ -0,0 +1,78 @@ +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(), + ], + ); + } +} diff --git a/app/Services/Mail/FakeMailbox.php b/app/Services/Mail/FakeMailbox.php index 05b1500..fd7afd6 100644 --- a/app/Services/Mail/FakeMailbox.php +++ b/app/Services/Mail/FakeMailbox.php @@ -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); diff --git a/app/Services/Mail/ImapMailbox.php b/app/Services/Mail/ImapMailbox.php index c7f1cea..ab3b384 100644 --- a/app/Services/Mail/ImapMailbox.php +++ b/app/Services/Mail/ImapMailbox.php @@ -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 */ @@ -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'])); diff --git a/app/Services/Mail/InboundMailStatus.php b/app/Services/Mail/InboundMailStatus.php new file mode 100644 index 0000000..c55a458 --- /dev/null +++ b/app/Services/Mail/InboundMailStatus.php @@ -0,0 +1,59 @@ + 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, + ]; + } +} diff --git a/app/Services/Mail/InboundMailbox.php b/app/Services/Mail/InboundMailbox.php index 3e240fb..ef63fa1 100644 --- a/app/Services/Mail/InboundMailbox.php +++ b/app/Services/Mail/InboundMailbox.php @@ -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. * diff --git a/app/Services/Mail/IngestInboundMail.php b/app/Services/Mail/IngestInboundMail.php index 15b5fc1..22e54f6 100644 --- a/app/Services/Mail/IngestInboundMail.php +++ b/app/Services/Mail/IngestInboundMail.php @@ -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) { diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php new file mode 100644 index 0000000..4d81734 --- /dev/null +++ b/app/Services/Mail/MailPreviews.php @@ -0,0 +1,219 @@ + 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; + } +} diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index 5a5398d..25d4ebc 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -19,7 +19,7 @@ class FakeStripeClient implements StripeClient /** @var array */ public array $products = []; - /** @var array */ + /** @var array */ public array $prices = []; /** @var array */ @@ -34,7 +34,25 @@ class FakeStripeClient implements StripeClient */ public array $activated = []; - /** Idempotency key → the id first returned for it. @var array */ + /** + * 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}> + */ + 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 + */ 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)]; + } + } } diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index 1a4e660..94c6cf7 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -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; diff --git a/app/Services/Stripe/IdempotencyKey.php b/app/Services/Stripe/IdempotencyKey.php new file mode 100644 index 0000000..d71f8ea --- /dev/null +++ b/app/Services/Stripe/IdempotencyKey.php @@ -0,0 +1,111 @@ + $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); + } +} diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index 841db2d..0ffed51 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -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}> + */ + 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; diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index b730faf..d6d7617 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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 diff --git a/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php b/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php new file mode 100644 index 0000000..359efba --- /dev/null +++ b/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php @@ -0,0 +1,107 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php b/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php new file mode 100644 index 0000000..be6eeff --- /dev/null +++ b/database/migrations/2026_07_31_220000_warn_before_a_dormant_account_is_removed.php @@ -0,0 +1,31 @@ +timestamp('dormant_warned_at')->nullable()->after('email_verified_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('dormant_warned_at'); + }); + } +}; diff --git a/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php b/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php new file mode 100644 index 0000000..d20b05d --- /dev/null +++ b/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php @@ -0,0 +1,81 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php b/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php new file mode 100644 index 0000000..9597400 --- /dev/null +++ b/database/migrations/2026_08_01_090000_take_the_billing_address_field_by_field.php @@ -0,0 +1,44 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php b/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php new file mode 100644 index 0000000..97123b7 --- /dev/null +++ b/database/migrations/2026_08_01_091000_record_why_a_contract_was_cancelled.php @@ -0,0 +1,35 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php new file mode 100644 index 0000000..61f93f2 --- /dev/null +++ b/database/migrations/2026_08_01_100000_keep_the_processing_agreement_and_who_accepted_it.php @@ -0,0 +1,76 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php new file mode 100644 index 0000000..42f4213 --- /dev/null +++ b/database/migrations/2026_08_01_101000_add_the_dpa_manage_permission.php @@ -0,0 +1,40 @@ +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(); + } +}; diff --git a/docs/handoffs/2026-07-30-real-run-handoff.md b/docs/handoffs/2026-07-30-real-run-handoff.md index 8f51e21..3364113 100644 --- a/docs/handoffs/2026-07-30-real-run-handoff.md +++ b/docs/handoffs/2026-07-30-real-run-handoff.md @@ -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, diff --git a/docs/superpowers/plans/2026-07-30-stripe-price-adoption.md b/docs/superpowers/plans/2026-07-30-stripe-price-adoption.md new file mode 100644 index 0000000..6334685 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-stripe-price-adoption.md @@ -0,0 +1,1883 @@ +# Stripe Price Adoption — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ein Abgleich, der zwischen Stripes Anlage und unserem Schreiben abbricht, erkennt den vorhandenen Preis beim nächsten Lauf wieder statt einen zweiten anzulegen — und eine Änderung an den Metadaten von `createPrice` blockiert Abgleich und Kundenbuchung nie wieder mit HTTP 400. + +**Architecture:** Zwei neue Bauteile. `App\Services\Stripe\IdempotencyKey` faltet einen Fingerabdruck der tatsächlich gesendeten Parameter in den Schlüssel — angewandt **nur** auf `createPrice`/`createProduct`, nie auf Geldbewegungen. `App\Services\Billing\AdoptStripePrice` fragt Stripe vor dem Anlegen nach den aktiven Preisen des Produkts und übernimmt einen beweisbar eigenen. Beide Preis-Dienste (`AddonPrices`, `PlanPrices`) bekommen den Wiedererkennungsschritt zwischen Tabellen-Blick und `createPrice`. Dazu ein eindeutiger Index auf `stripe_addon_prices.stripe_price_id` als Netz darunter. + +**Tech Stack:** Laravel 11, Pest, Livewire (nicht berührt), Stripe REST über `Illuminate\Http\Client`, MariaDB in Produktion / SQLite `:memory:` in Tests. + +**Spec:** `docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md` + +## Global Constraints + +- **Commit-Disziplin (Nutzervorgabe, nicht verhandelbar):** Eine zweite Session arbeitet im selben Git-Index. **Immer** `git add -- ` und `git commit -F - -- `. **Nie** `git add -A`, `git add .`, `git commit -a`. +- **Sprache:** Code, Kommentare, Log-Meldungen und Commit-Botschaften **englisch**. Spec/Plan/Handoff-Dokumente **deutsch**. Kommentare erklären das *Warum*, nicht das *Was* — das ist die Hausschrift dieses Repos, siehe die vorhandenen Klassenkommentare in `app/Services/Billing/`. +- **Bestehende Verträge behalten ihre alten Preise.** Neue Preise gelten nur für neu abschließende Kunden. Keine Änderung dieses Plans darf einen laufenden Vertrag auf einen anderen Betrag bewegen. +- **Jede Buchung friert ihren Preis ein.** Ein später gebuchtes Modul kommt zum heutigen Preis; eine Stornierung zieht den damaligen Betrag ab. +- **Der Fingerabdruck ist ausschließlich für `createPrice` und `createProduct`.** Bei `refund`, `cancelSubscription`, `addSubscriptionItem` und `createCheckoutSession` muss Stripes HTTP 400 bei geänderten Parametern erhalten bleiben — dort ist ein Duplikat doppelt abgebuchtes Kundengeld. +- **`TaxTreatment` bleibt die einzige Steuerinstanz.** `automatic_tax` bleibt aus. +- **Kein Testlauf mit `--filter` als Abschluss:** jede Task endet mit dem vollen Billing-Ordner (`php artisan test --testsuite=Feature tests/Feature/Billing`), bevor committet wird. +- **Zahlen für Tests, aus dem echten Katalog:** `priority_support` = `2900` Netto (`config/provisioning.php:308`), Steuersatz 20 % → `3480` verrechnet monatlich, `34800` Netto/`41760` verrechnet jährlich. `3480 EUR monatlich` ist genau der Preis aus dem Vorfall (`price_1TygdEC7u8NpJ8pOt3nsoyYw`). + +--- + +## Korrekturen + +**Dieses Dokument ist nicht geprüft, sondern korrigiert.** Die Reviews der +einzelnen Tasks haben **fünf Defekte im Plan selbst** gefunden — nicht in der +Umsetzung. Sie stehen hier, damit niemand die Anweisungen weiter unten für +verifiziert hält. Der vollständige Verlauf, Task für Task, liegt in +`.superpowers/sdd/2026-07-30-stripe-price-adoption/progress.md`. + +1. **Ein zerstörerischer Befehl.** Task 6, Step 5 verlangte + `php artisan migrate:fresh --env=testing`. Es gibt in diesem Repo keine + `.env.testing`, also hätte der Befehl die echte, von laufenden Containern + benutzte Entwicklungsdatenbank neu gebaut. Der Umsetzer hat den Schritt + verweigert — richtig — und den Index über den SQLite-Migrationslauf der Suite + belegt. Der Schritt steht unten durchgestrichen mit Warnung, statt gelöscht. +2. **Eine Begründung, deren Mechanismus nicht feuern kann.** Der + Migrations-Kommentar im vorgeschriebenen Codeblock von Task 6 sagt, die + gelöschte Zeile baue sich wieder auf, indem `ensure()` den Preis „through the + adoption step" bei Stripe findet. Genau das kann nicht passieren: nach dem + Entdoppeln beansprucht die überlebende Zeile diese Preis-ID, also lehnt + `AdoptStripePrice` sie über die `claimed`-Prüfung immer ab. Der Wiederaufbau + ist echt, läuft aber über `createPrice()` unter dem tupel-eigenen Schlüssel. + Die ausgelieferte Migration ist berichtigt; der Plantext schreibt die falsche + Fassung weiter vor. Die Commit-Botschaft in Step 8 sagt nur „the row rebuilds + itself on the next ensure()" und nennt den Mechanismus nicht — das ist wahr, + aber es ist nicht die Begründung, die ausgeliefert wurde. +3. **Ein tautologischer Test.** Der einzige Test, der in Task 4 die + Auftraggeber-Regel „jede Buchung friert ihren Preis ein" bewachte, prüfte + `subscription_addons.stripe_price_id` nach einem `stripe:sync-catalogue`-Lauf — + eine Spalte, die dieses Kommando nie schreibt. Er war grün, bevor + `AddonPrices.php` überhaupt angefasst war. +4. **Zwei Plan-Tests, die mit entfernter Übernahme bestanden.** Beide neuen Tests + aus Task 5 liefen grün, nachdem der `adopt()`-Aufruf entfernt war. Ursache: + sie erzeugten ihre Waise über einen echten Sync-Lauf, sodass der + Schlüssel-Ledger des Fakes dieselbe Preis-ID zurückspielte — also genau der + Zustand (Schlüssel noch in Kraft), in dem es nichts zu beweisen gibt. Der + Vorfall vom 29.07. konnte erst **nach** dem Ablauf des Schlüssels entstehen. +5. **Drei falsche Testzahlen.** Die „Expected"-Zeilen von Task 1, 2 und 3 nennen + sieben, zehn und acht Tests; die dort vorgeschriebenen Dateien enthalten sechs, + neun und sieben. + +Die Abschnitte darunter sind **absichtlich nicht umgeschrieben** — die +vorgeschriebenen Codeblöcke bleiben stehen, wie sie umgesetzt wurden, damit Plan +und Verlauf vergleichbar bleiben. Diese Liste ist die Korrektur. + +--- + +## Dateistruktur + +**Neu** + +| Datei | Verantwortung | +|---|---| +| `app/Services/Stripe/IdempotencyKey.php` | Bildet Header-Werte: sprechender Schlüssel + Fingerabdruck der Aufrufparameter. Die **einzige** Stelle, an der ein Fingerabdruck entsteht — beide Clients müssen denselben Wert bilden. | +| `app/Services/Billing/AdoptStripePrice.php` | Der Wiedererkennungsschritt und seine fünf Bedingungen. Weiß nichts über Module oder Pakete; bekommt Produkt, Betrag, Metadaten und eine Frage „ist diese ID schon beansprucht?" gestellt. | +| `database/migrations/2026_07_31_210000_one_row_per_stripe_price.php` | Entdoppelt `stripe_addon_prices` und macht `stripe_price_id` eindeutig. | +| `tests/Feature/Billing/StripeIdempotencyKeyTest.php` | Was auf der Leitung landet (`Http::fake` gegen `HttpStripeClient`) und dass der Fake Stripe nachbildet. | +| `tests/Feature/Billing/StripePriceAdoptionTest.php` | Die Übernahmeregeln, beide Seiten, plus der eindeutige Index und die eingefrorene Buchung. | + +**Geändert** + +| Datei | Änderung | +|---|---| +| `app/Services/Stripe/StripeClient.php` | Zwei Methoden am Vertrag: `activePricesFor()`, `updatePriceMetadata()`. | +| `app/Services/Stripe/HttpStripeClient.php` | Beide Methoden umgesetzt; Fingerabdruck an `createPrice`/`createProduct`. | +| `app/Services/Stripe/FakeStripeClient.php` | Beide Methoden; `created` an den Preisen; `plantPrice()` für Tests; Schlüssel-Ledger vergleicht Parameter. | +| `app/Services/Billing/AddonPrices.php` | Wiedererkennung vor `createPrice`; Schlüssel-Kommentar berichtigt. | +| `app/Services/Billing/PlanPrices.php` | Dasselbe. | +| `docs/handoffs/2026-07-30-real-run-handoff.md` | Block-D-Punkt zur Eindeutigkeit als erledigt markieren. | + +**Reihenfolge und Abhängigkeit:** Task 1 (Schlüssel) und Task 2 (Client-Methoden) sind unabhängig voneinander. Task 3 braucht Task 2. Task 4 und 5 brauchen Task 3. Task 6 ist unabhängig, kommt aber zuletzt, weil ihr Test die Übernahme aus Task 4 mitprüft. + +--- + +### Task 1: Der Idempotenz-Schlüssel trägt, was gesendet wird + +**Files:** +- Create: `app/Services/Stripe/IdempotencyKey.php` +- Modify: `app/Services/Stripe/HttpStripeClient.php:87-121` (`createProduct`, `createPrice`) +- Modify: `app/Services/Stripe/FakeStripeClient.php:37-38` (Ledger-Eigenschaft), `:151-193` (`createProduct`, `createPrice`), `:313`, `:337-350` (`cancelSubscription`, `refund`) +- Test: `tests/Feature/Billing/StripeIdempotencyKeyTest.php` + +**Interfaces:** +- Consumes: nichts. +- Produces: + - `IdempotencyKey::forPrice(?string $key, string $productId, int $amountCents, string $currency, string $interval, array $metadata): ?string` + - `IdempotencyKey::forProduct(?string $key, string $name, array $metadata): ?string` + - `IdempotencyKey::fingerprint(array $parameters): string` — acht Hex-Zeichen + - `FakeStripeClient::$keys` wechselt von `array` zu `array`. **Kein Test liest die Eigenschaft** (geprüft), nur der Fake selbst an vier Stellen. + +- [ ] **Step 1: Write the failing test** + +`tests/Feature/Billing/StripeIdempotencyKeyTest.php`: + +```php +set('services.stripe.secret', 'sk_test_plan_task_one'); +}); + +it('sends a different key once the metadata changes', function () { + Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]); + + $client = new HttpStripeClient; + $spoken = 'clupilot-addon-price-priority_support-month-3480-EUR'; + + // The call as it stood before 9da1358, and the call after it: same money, + // same interval, one metadata field more. + $client->createPrice('prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], $spoken); + + $client->createPrice('prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], $spoken); + + $sent = collect(Http::recorded()) + ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) + ->all(); + + expect($sent[0])->toStartWith($spoken) + ->and($sent[1])->toStartWith($spoken) + ->and($sent[1])->not->toBe($sent[0]); +}); + +it('sends the same key for the very same call', function () { + Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_x'])]); + + $client = new HttpStripeClient; + + foreach ([1, 2] as $ignored) { + $client->createPrice('prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], 'clupilot-addon-price'); + } + + $sent = collect(Http::recorded()) + ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) + ->unique() + ->all(); + + expect($sent)->toHaveCount(1); +}); + +it('fingerprints the product call too, where the same trap was waiting', function () { + Http::fake(['api.stripe.com/*' => Http::response(['id' => 'prod_x'])]); + + $client = new HttpStripeClient; + + $client->createProduct('Priority Support', ['addon' => 'priority_support'], 'clupilot-addon-product-x'); + $client->createProduct('Priority Support', ['addon' => 'priority_support', 'sold_as' => 'entitlement'], 'clupilot-addon-product-x'); + + $sent = collect(Http::recorded()) + ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) + ->all(); + + expect($sent[1])->not->toBe($sent[0]); +}); + +it('leaves the money calls their bare key, so Stripe still refuses a changed one', function () { + Http::fake(['api.stripe.com/*' => Http::response(['id' => 'x'])]); + + $client = new HttpStripeClient; + + $client->refund('pi_1', 500, 'clupilot-refund-7'); + $client->cancelSubscription('sub_1', 'at_period_end', 'clupilot-cancel-7'); + $client->addSubscriptionItem('sub_1', 'price_1', 1, 'none', 'clupilot-item-7'); + + $sent = collect(Http::recorded()) + ->map(fn (array $pair) => $pair[0]->header('Idempotency-Key')[0] ?? null) + ->all(); + + expect($sent)->toBe(['clupilot-refund-7', 'clupilot-cancel-7', 'clupilot-item-7']); +}); + +it('reproduces the refusal Stripe makes, which the fake used to swallow', function () { + $fake = new FakeStripeClient; + + $fake->refund('pi_1', 500, 'clupilot-refund-7'); + + // Same key, different amount. Stripe answers 400; the fake said nothing and + // replayed the first refund's id, which is how a test could pass over the + // very failure that stopped production. + expect(fn () => $fake->refund('pi_1', 900, 'clupilot-refund-7')) + ->toThrow(RuntimeException::class, 'same parameters'); +}); + +it('mints a second price rather than blocking when the metadata moved', function () { + $fake = new FakeStripeClient; + + $first = $fake->createPrice('prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], 'clupilot-addon-price'); + + $second = $fake->createPrice('prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support', 'tax_treatment' => 'domestic'], 'clupilot-addon-price'); + + // Two objects, no exception. That the second one is not WANTED is the job of + // AdoptStripePrice, not of the key — see StripePriceAdoptionTest. + expect($second)->not->toBe($first); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +Expected: FAIL — `Class "App\Services\Stripe\IdempotencyKey" not found` erscheint noch nicht, weil der Test die Klasse nicht direkt anfasst; stattdessen schlagen die Erwartungen fehl (`$sent[1]` **ist** gleich `$sent[0]`), und der Refund-Test schlägt fehl, weil der Fake nicht wirft. + +- [ ] **Step 3: Write `IdempotencyKey`** + +`app/Services/Stripe/IdempotencyKey.php`: + +```php + $productId, + 'unit_amount' => $amountCents, + 'currency' => strtolower($currency), + 'interval' => $interval, + 'metadata' => $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, ['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); + } +} +``` + +- [ ] **Step 4: Wire it into `HttpStripeClient`** + +`app/Services/Stripe/HttpStripeClient.php` — `createProduct` (ab `:87`): + +```php + public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string + { + return (string) $this->request(IdempotencyKey::forProduct($idempotencyKey, $name, $metadata)) + ->asForm() + ->post($this->url('products'), array_filter([ + 'name' => $name, + ...$this->flatten('metadata', $metadata), + ])) + ->throw() + ->json('id'); + } +``` + +`createPrice` (ab `:99`) — nur die erste Zeile des `return` ändert sich: + +```php + return (string) $this->request(IdempotencyKey::forPrice( + $idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata, + )) + ->asForm() + ->post($this->url('prices'), [ +``` + +Und den Kommentar in `request()` (`:344-351`) ergänzen — er behauptet heute, der Schlüssel schütze einen Retry, und verschweigt, dass er einen geänderten Aufruf abwürgt: + +```php + // Stripe replays the original response for a repeated key instead of + // 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. +``` + +`use App\Services\Stripe\IdempotencyKey;` ist **nicht** nötig — dieselbe Namespace. + +- [ ] **Step 5: Make the fake faithful** + +`app/Services/Stripe/FakeStripeClient.php` — Eigenschaft ersetzen (`:37-38`): + +```php + /** + * 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 + */ + public array $keys = []; +``` + +Zwei private Helfer am Ende der Klasse, neben `failIfAsked()`: + +```php + /** + * 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)]; + } + } +``` + +`createProduct` (`:151-166`) — Schlüssel **mit Fingerabdruck**, genau wie der HTTP-Client, sonst prüft der Test etwas anderes als die Produktion sendet: + +```php + public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string + { + $key = IdempotencyKey::forProduct($idempotencyKey, $name, $metadata); + $parameters = ['name' => $name, 'metadata' => $metadata]; + + // Replays the first answer for a repeated key, as Stripe does. + $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]; + + $this->rememberKey($key, $id, $parameters); + + return $id; + } +``` + +`createPrice` (`:168-194`) — dasselbe Muster, plus `created` am gespeicherten Preis (Task 3 sortiert danach): + +```php + public function createPrice( + string $productId, + int $amountCents, + string $currency, + string $interval, + array $metadata = [], + ?string $idempotencyKey = null, + ): string { + $key = IdempotencyKey::forPrice($idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata); + $parameters = [ + 'product' => $productId, + 'unit_amount' => $amountCents, + 'currency' => strtolower($currency), + 'interval' => $interval, + 'metadata' => $metadata, + ]; + + $replayed = $this->replay($key, $parameters); + + if ($replayed !== null) { + return $replayed; + } + + $id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12); + $this->prices[$id] = [ + 'product' => $productId, + 'amount' => $amountCents, + 'currency' => $currency, + 'interval' => $interval, + 'metadata' => $metadata, + // Stripe stamps every object with its creation time and + // AdoptStripePrice takes the OLDEST of several orphans. A counter + // is enough and beats a clock: it cannot tie. + 'created' => count($this->prices) + 1, + ]; + + $this->rememberKey($key, $id, $parameters); + + return $id; + } +``` + +Das `@var` der Eigenschaft `$prices` (`:22`) um `created: int` ergänzen. + +`cancelSubscription` (`:313`, `:324`) und `refund` (`:337`, `:350`) auf `replay()`/`rememberKey()` umstellen — **ohne** `IdempotencyKey`, mit dem rohen Schlüssel: + +```php + // 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; + } +``` + +und nach dem Anlegen `$this->rememberKey($idempotencyKey, $id, $parameters);`. Für `cancelSubscription` sind die Parameter `['subscription' => $subscriptionId, 'when' => $when]`, die gemerkte „id" bleibt `$subscriptionId` wie heute. + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +php artisan test tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +Expected: PASS, sieben Tests. + +- [ ] **Step 7: Run the whole billing folder — the fake is used by a dozen files** + +```bash +php artisan test tests/Feature/Billing +``` + +Expected: PASS. Wenn hier etwas fällt, ist es ein Test, der einen Schlüssel zweimal mit verschiedenen Parametern schickte und das bisher nicht merkte — **das ist ein echter Fund**, nicht ein zu unterdrückender Testfehler: den Aufrufer korrigieren, nicht den Fake. + +- [ ] **Step 8: Commit** + +```bash +git add -- app/Services/Stripe/IdempotencyKey.php app/Services/Stripe/HttpStripeClient.php app/Services/Stripe/FakeStripeClient.php tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +```bash +git commit -F - -- app/Services/Stripe/IdempotencyKey.php app/Services/Stripe/HttpStripeClient.php app/Services/Stripe/FakeStripeClient.php tests/Feature/Billing/StripeIdempotencyKeyTest.php <<'MSG' +Put in the key everything the call actually sends + +A key says "I already sent this call", not "this is what the object is". The +catalogue calls sent metadata that the key knew nothing about, so adding the +tax_treatment field in 9da1358 poisoned yesterday's key for a day — and +AddonPrices::ensure() runs inside a customer's module booking, not only in the +sweep. + +createPrice and createProduct now fold a fingerprint of their parameters into +the key. refund, cancelSubscription, addSubscriptionItem and the checkout +deliberately do not: there a second object is the customer's money taken twice, +and Stripe's refusal is the thing worth keeping. + +The fake could not see any of this. Its ledger replayed the first id for a +repeated key without ever comparing what the second call asked for. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +### Task 2: Stripe fragen, was es schon hat + +**Files:** +- Modify: `app/Services/Stripe/StripeClient.php:118-140` (zwischen `createPrice` und `archivePrice`) +- Modify: `app/Services/Stripe/HttpStripeClient.php` (nach `activatePrice`, `:143`) +- Modify: `app/Services/Stripe/FakeStripeClient.php` (nach `activatePrice`, `:201-212`) +- Test: `tests/Feature/Billing/StripeIdempotencyKeyTest.php` (zwei Tests angehängt — dieselbe Datei, weil es wieder um das geht, was auf der Leitung landet) + +**Interfaces:** +- Consumes: nichts aus Task 1. +- Produces: + - `StripeClient::activePricesFor(string $productId): array` — Liste von + `array{id: string, unit_amount: int, currency: string, interval: string, created: int, metadata: array}`. + **`currency` kommt GROSSGESCHRIEBEN zurück** (Stripe liefert klein; unsere Tabellen halten groß), `interval` ist `month`/`year`. + - `StripeClient::updatePriceMetadata(string $priceId, array $metadata): void` + - `FakeStripeClient::plantPrice(string $id, string $productId, int $amountCents, string $currency, string $interval, array $metadata, int $created = 1): void` + - `FakeStripeClient::$metadataUpdates` — `array}>` + +- [ ] **Step 1: Write the failing test** + +An `tests/Feature/Billing/StripeIdempotencyKeyTest.php` anhängen: + +```php +it('pages through every active price of a product', function () { + Http::fake([ + 'api.stripe.com/*' => Http::sequence() + ->push([ + 'data' => [ + ['id' => 'price_a', 'unit_amount' => 3480, 'currency' => 'eur', + 'created' => 100, 'recurring' => ['interval' => 'month'], + 'metadata' => ['addon' => 'priority_support']], + ['id' => 'price_b', 'unit_amount' => 41760, 'currency' => 'eur', + 'created' => 101, 'recurring' => ['interval' => 'year'], 'metadata' => []], + ], + 'has_more' => true, + ]) + ->push([ + 'data' => [ + ['id' => 'price_c', 'unit_amount' => 2900, 'currency' => 'eur', + 'created' => 102, 'recurring' => ['interval' => 'month'], + 'metadata' => ['addon' => 'priority_support', 'tax_treatment' => 'reverse_charge']], + ], + 'has_more' => false, + ]), + ]); + + $prices = (new HttpStripeClient)->activePricesFor('prod_1'); + + expect($prices)->toHaveCount(3) + ->and($prices[0])->toBe([ + 'id' => 'price_a', + 'unit_amount' => 3480, + // Upper case, because that is how our own tables hold it and the + // comparison in AdoptStripePrice must not have to remember which + // side is which. + 'currency' => 'EUR', + 'interval' => 'month', + 'created' => 100, + 'metadata' => ['addon' => 'priority_support'], + ]) + ->and($prices[2]['id'])->toBe('price_c'); + + // The second page has to be asked for, or this reintroduces the very gap it + // exists to close — a family product accumulates prices across versions, + // terms, treatments and every rate change. + Http::assertSent(fn ($request) => str_contains($request->url(), 'starting_after=price_b')); + + // Archived prices are none of our business here: we are looking for + // something to SELL on. + Http::assertSent(fn ($request) => str_contains($request->url(), 'active=true')); +}); + +it('writes metadata onto a price that already exists', function () { + Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_a'])]); + + (new HttpStripeClient)->updatePriceMetadata('price_a', ['addon' => 'priority_support']); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.stripe.com/v1/prices/price_a' + && $request['metadata[addon]'] === 'priority_support'); +}); + +it('lets the fake answer with the prices it holds, minus the archived ones', function () { + $fake = new FakeStripeClient; + + $kept = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support']); + $gone = $fake->createPrice('prod_1', 2900, 'EUR', 'month', ['addon' => 'priority_support']); + $other = $fake->createPrice('prod_2', 3480, 'EUR', 'month', []); + $fake->archivePrice($gone); + + $fake->plantPrice('price_orphan', 'prod_1', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], created: 0); + + $found = collect($fake->activePricesFor('prod_1'))->pluck('id')->all(); + + expect($found)->toContain($kept, 'price_orphan') + ->and($found)->not->toContain($gone, $other); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +Expected: FAIL — `Call to undefined method App\Services\Stripe\HttpStripeClient::activePricesFor()`. + +- [ ] **Step 3: Add both methods to the contract** + +`app/Services/Stripe/StripeClient.php`, zwischen `createPrice` und `archivePrice`: + +```php + /** + * 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. + * + * @return array}> + */ + 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; +``` + +- [ ] **Step 4: Implement in `HttpStripeClient`** (nach `activatePrice`) + +```php + 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) { + $prices[] = [ + 'id' => (string) ($price['id'] ?? ''), + 'unit_amount' => (int) ($price['unit_amount'] ?? 0), + 'currency' => strtoupper((string) ($price['currency'] ?? '')), + 'interval' => (string) ($price['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(); + } +``` + +- [ ] **Step 5: Implement in `FakeStripeClient`** (nach `activatePrice`) + +```php + 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. + $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, + ]; + } +``` + +Eigenschaft dazu, neben `$activated`: + +```php + /** + * 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}> + */ + public array $metadataUpdates = []; +``` + +- [ ] **Step 6: Run the tests** + +```bash +php artisan test tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +Expected: PASS, zehn Tests. + +- [ ] **Step 7: Run the billing folder** + +```bash +php artisan test tests/Feature/Billing +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -- app/Services/Stripe/StripeClient.php app/Services/Stripe/HttpStripeClient.php app/Services/Stripe/FakeStripeClient.php tests/Feature/Billing/StripeIdempotencyKeyTest.php +``` + +```bash +git commit -F - -- app/Services/Stripe/StripeClient.php app/Services/Stripe/HttpStripeClient.php app/Services/Stripe/FakeStripeClient.php tests/Feature/Billing/StripeIdempotencyKeyTest.php <<'MSG' +Let the catalogue ask Stripe what it already has + +Nothing here could. The client could create, archive and unarchive a Price and +had no way to list one, so a run that died between Stripe's create and our +insert left an orphan our table never learned about — and once the key expired, +the next run made a second live Price for the same money. + +Paged to the end like invoiceLines(), because a family Product collects a Price +per version, term, treatment and rate change, and stopping at the first page +would leave the orphan unfound and mint the duplicate anyway. + +updatePriceMetadata() alongside it: metadata is one of the few fields a Price +lets you change, which is why the metadata format is not part of a Price's +identity and why an adopted orphan is brought up to date rather than replaced. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +### Task 3: `AdoptStripePrice` — die fünf Bedingungen + +**Files:** +- Create: `app/Services/Billing/AdoptStripePrice.php` +- Test: `tests/Feature/Billing/StripePriceAdoptionTest.php` + +**Interfaces:** +- Consumes: `StripeClient::activePricesFor()`, `updatePriceMetadata()`, `archivePrice()` aus Task 2. `FakeStripeClient::plantPrice()`, `$metadataUpdates`. +- Produces: + +```php +public function __invoke( + string $productId, + int $amountCents, + string $currency, + string $interval, + array $metadata, + array $identifying, + callable $claimed, // fn (string $priceId): bool +): ?string +``` + + Gibt die übernommene Preis-ID zurück oder `null`, wenn nichts zu übernehmen ist. `$identifying` sind die Metadatenschlüssel, die einen Preis als unseren ausweisen (`['addon']` bzw. `['plan_price_id']`). + +- [ ] **Step 1: Write the failing test** + +`tests/Feature/Billing/StripePriceAdoptionTest.php`: + +```php +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); +}); + +/** The module metadata as AddonPrices sends it today. */ +function moduleMetadata(string $treatment = 'domestic'): array +{ + return ['addon' => 'priority_support', 'tax_treatment' => $treatment]; +} + +/** Ask the adoption step the question AddonPrices asks it. */ +function adoptModulePrice(?array $metadata = null, ?callable $claimed = null): ?string +{ + return app(AdoptStripePrice::class)( + productId: 'prod_support', + amountCents: 3480, + currency: 'EUR', + interval: 'month', + metadata: $metadata ?? moduleMetadata(), + identifying: ['addon'], + claimed: $claimed ?? fn (string $id) => false, + ); +} + +it('adopts the orphan of 2026-07-29 instead of minting a second price', function () { + // The state that morning: the Price exists at Stripe, carries the metadata + // of the code that made it — WITHOUT tax_treatment, which 9da1358 added + // afterwards — and no row of ours knows it. + $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', + 3480, 'EUR', 'month', ['addon' => 'priority_support']); + + expect(adoptModulePrice())->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw'); + + // Brought up to today's metadata rather than replaced: metadata is mutable + // at Stripe, the amount is not, which is the whole reason the format is no + // part of a Price's identity. + expect($this->stripe->metadataUpdates)->toBe([[ + 'price' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw', + 'metadata' => moduleMetadata(), + ]]); +}); + +it('leaves the metadata alone when it already says the right thing', function () { + $this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', moduleMetadata()); + + expect(adoptModulePrice())->toBe('price_ok') + ->and($this->stripe->metadataUpdates)->toBe([]); +}); + +it('adopts nothing when the amount, currency or interval differ', function () { + $this->stripe->plantPrice('price_cheaper', 'prod_support', 2900, 'EUR', 'month', moduleMetadata()); + $this->stripe->plantPrice('price_yearly', 'prod_support', 3480, 'EUR', 'year', moduleMetadata()); + $this->stripe->plantPrice('price_dollars', 'prod_support', 3480, 'USD', 'month', moduleMetadata()); + + expect(adoptModulePrice())->toBeNull(); +}); + +it('refuses a price nothing proves is ours, and says so', function () { + Log::spy(); + + // What a person clicking through Stripe's own dashboard leaves behind: the + // right money on our product, and not one word about what it is for. + $this->stripe->plantPrice('price_by_hand', 'prod_support', 3480, 'EUR', 'month', []); + + expect(adoptModulePrice())->toBeNull(); + + Log::shouldHaveReceived('warning')->once(); +}); + +it('passes silently over another of our own prices', function () { + Log::spy(); + + // At a VAT rate of nought both treatments are the same amount, so the + // reverse-charge Price sits at the domestic one's money — and contradicts on + // tax_treatment. That is not a mystery worth a warning; it is a Price of + // ours that is not the one being asked for. + $this->stripe->plantPrice('price_rc', 'prod_support', 3480, 'EUR', 'month', + moduleMetadata('reverse_charge')); + + expect(adoptModulePrice())->toBeNull(); + + Log::shouldNotHaveReceived('warning'); +}); + +it('never hands out a price a row already claims', function () { + $this->stripe->plantPrice('price_taken', 'prod_support', 3480, 'EUR', 'month', moduleMetadata()); + + expect(adoptModulePrice(claimed: fn (string $id) => $id === 'price_taken'))->toBeNull(); +}); + +it('adopts the oldest of several orphans and stops selling the rest', function () { + Log::spy(); + + $this->stripe->plantPrice('price_second', 'prod_support', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], created: 200); + $this->stripe->plantPrice('price_first', 'prod_support', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], created: 100); + $this->stripe->plantPrice('price_third', 'prod_support', 3480, 'EUR', 'month', + ['addon' => 'priority_support'], created: 300); + + // The oldest, because it is the one a lost row is likeliest to have been + // billing on. + expect(adoptModulePrice())->toBe('price_first') + ->and($this->stripe->archived)->toBe(['price_second', 'price_third']); + + Log::shouldHaveReceived('warning'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: FAIL — `Target class [App\Services\Billing\AdoptStripePrice] does not exist.` + +- [ ] **Step 3: Write the implementation** + +`app/Services/Billing/AdoptStripePrice.php`: + +```php + $metadata what the create call would send + * @param array $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 { + $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. + if ($adopted['metadata'] !== $metadata) { + $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 $found + * @param array $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 $found + * @param array $expected + * @param array $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; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: PASS, acht Tests. + +- [ ] **Step 5: Commit** + +```bash +git add -- app/Services/Billing/AdoptStripePrice.php tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +```bash +git commit -F - -- app/Services/Billing/AdoptStripePrice.php tests/Feature/Billing/StripePriceAdoptionTest.php <<'MSG' +Recognise the price 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 key covers a day; after that the next run makes a second live Price +for the same money. + +What may be taken over is narrow. Same amount, currency and interval — a Price +at another figure would move money, and nothing here may: a running contract +keeps the Price it was sold on, and a booking stays frozen at what it cost that +day. Plus proof in the metadata, because an unexplained active Price at the +right money is what somebody clicking through Stripe's dashboard leaves behind, +and adopting that is worse than minting a second one. + +Several orphans: the oldest is adopted — likeliest to be the one a lost row was +billing on — and the rest are archived, which stops them being sold and moves +nobody. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +### Task 4: Die Modulseite benutzt den Wiedererkennungsschritt + +**Files:** +- Modify: `app/Services/Billing/AddonPrices.php:47` (Konstruktor), `:126-159` (der Anlege-Pfad in `ensure`) +- Test: `tests/Feature/Billing/StripePriceAdoptionTest.php` (angehängt) + +**Interfaces:** +- Consumes: `AdoptStripePrice::__invoke()` aus Task 3. +- Produces: `AddonPrices::__construct(StripeClient $stripe, AdoptStripePrice $adopt)`. **Geprüft: niemand konstruiert `AddonPrices` mit `new`** (weder App noch Tests), der Container löst auf. + +- [ ] **Step 1: Write the failing test** + +An `tests/Feature/Billing/StripePriceAdoptionTest.php` anhängen: + +```php +use App\Models\StripeAddonPrice; +use App\Models\Subscription; +use App\Models\SubscriptionAddon; +use App\Services\Billing\AddonPrices; +use App\Services\Billing\TaxTreatment; +use Illuminate\Contracts\Console\Kernel; + +it('takes the orphan over instead of minting a second module price', function () { + // The product exists because a previous run got that far; the Price exists + // because the run that made it died before the insert. + $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', + 3480, 'EUR', 'month', ['addon' => 'priority_support']); + StripeAddonPrice::query()->create([ + 'addon_key' => 'priority_support', 'reverse_charge' => false, + 'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR', + 'interval' => 'year', 'stripe_product_id' => 'prod_support', + 'stripe_price_id' => 'price_yearly_already_known', + ]); + + $before = count($this->stripe->prices); + + $id = app(AddonPrices::class)->ensure( + 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), + ); + + expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw') + // Nothing new at Stripe: the orphan was taken over, not replaced. + ->and(count($this->stripe->prices))->toBe($before) + ->and(StripeAddonPrice::query() + ->where('addon_key', 'priority_support') + ->where('interval', 'month') + ->where('reverse_charge', false) + ->value('stripe_price_id'))->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw'); +}); + +it('does not block a booking because the metadata format moved', function () { + // 2026-07-29, exactly: orphan with the old metadata, code with the new. This + // is the call that answered HTTP 400 for twenty-four hours. + $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', + 3480, 'EUR', 'month', ['addon' => 'priority_support']); + StripeAddonPrice::query()->create([ + 'addon_key' => 'priority_support', 'reverse_charge' => false, + 'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR', + 'interval' => 'year', 'stripe_product_id' => 'prod_support', + 'stripe_price_id' => 'price_yearly_already_known', + ]); + + $id = app(AddonPrices::class)->ensure( + 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), + ); + + expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw') + ->and($this->stripe->metadataUpdates)->toHaveCount(1); +}); + +it('leaves a frozen booking on the price it was sold at', function () { + app(Kernel::class)->call('stripe:sync-catalogue'); + + $sold = app(AddonPrices::class)->liveFor( + 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), + ); + + $subscription = Subscription::factory()->plan('team')->create(); + $booking = SubscriptionAddon::query()->create([ + 'subscription_id' => $subscription->id, + 'addon_key' => 'priority_support', + // Net, per month, per unit — frozen at booking. `currency` and + // `booked_at` are both NOT NULL (2026_07_26_060000), and `uuid` fills + // itself through the model's uniqueIds(). + 'price_cents' => 2900, + 'currency' => 'EUR', + 'quantity' => 1, + 'booked_at' => now(), + 'stripe_price_id' => $sold, + ]); + + // The catalogue moves, and somebody has already left an orphan at the new + // figure. Neither may reach a booking that is already frozen. + config()->set('provisioning.addons.priority_support.price_cents', 3900); + $this->stripe->plantPrice('price_orphan_new_figure', 'prod_support', + 4680, 'EUR', 'month', ['addon' => 'priority_support']); + + app(Kernel::class)->call('stripe:sync-catalogue'); + + expect($booking->refresh()->stripe_price_id)->toBe($sold) + ->and($this->stripe->archived)->not->toContain($sold); +}); +``` + +**Zum letzten Test:** `SubscriptionAddon::booted()` wirft bei jeder Änderung an `FROZEN` (`subscription_id`, `addon_key`, `price_cents`, `currency`, `quantity`, `booked_at`) — die Regel „eine Buchung friert ihren Preis ein" steht also schon im Modell. `stripe_price_id` gehört nicht dazu und ist damit änderbar; genau deshalb muss ein Test sagen, dass die Übernahme sie nicht anfasst. `Subscription::factory()->plan('team')` ist die Form aus `tests/Feature/Billing/ReverseChargePriceTest.php:220`. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: FAIL — `ensure()` legt einen neuen Preis an, `count($this->stripe->prices)` ist um eins gewachsen, und die zurückgegebene ID ist nicht die der Waise. + +- [ ] **Step 3: Wire the adoption into `AddonPrices`** + +Konstruktor (`:47`): + +```php + public function __construct( + private readonly StripeClient $stripe, + private readonly AdoptStripePrice $adopt, + ) {} +``` + +Der Anlege-Pfad in `ensure()` (`:126-159`) wird zu: + +```php + $productId = $this->product($addonKey); + + $metadata = [ + // Read back when a Stripe invoice line has to be turned into + // wording a customer can read — see StripeInvoiceLines. + 'addon' => $addonKey, + // Which of the module's two Prices this is, for anyone reading + // Stripe's own dashboard, where they would otherwise differ only + // by an amount. + 'tax_treatment' => $reverseCharge ? 'reverse_charge' : 'domestic', + ]; + + // 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 below can never again refuse the call for a day. + idempotencyKey: "clupilot-addon-price-{$addonKey}-{$interval}-{$amount}-{$currency}" + .($reverseCharge ? '-rc' : ''), + ); + + $this->remember($addonKey, $reverseCharge, $amount, $netCents, $currency, $interval, $productId, $priceId); + $this->archiveSuperseded($addonKey, $reverseCharge, $netCents, $currency, $interval, $amount); + + return $priceId; +``` + +`use App\Services\Billing\AdoptStripePrice;` ist nicht nötig (gleiche Namespace). + +Den Kommentar in `remember()` (`:297-301`) um den zweiten Grund ergänzen, aus dem die Ausnahme kommen kann: + +```php + } catch (UniqueConstraintViolationException) { + // 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. The + // same catch now also covers a Price id another row already claims, + // which the unique index refuses: the caller is handed the id + // regardless, because refusing would fail a customer's booking over + // a register that is one row short. + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: PASS, elf Tests. + +- [ ] **Step 5: Run the billing folder** + +```bash +php artisan test tests/Feature/Billing +``` + +Expected: PASS. Ein Fehler in `StripeAddonBillingTest` oder `AddonCancellationTest` bedeutet, dass der Wiedererkennungsschritt einen Preis übernimmt, wo ein Test einen neuen erwartet — dann prüfen, ob die Erwartung des Tests oder die Bedingung in `AdoptStripePrice` falsch ist, **bevor** irgendetwas geändert wird. + +- [ ] **Step 6: Commit** + +```bash +git add -- app/Services/Billing/AddonPrices.php tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +```bash +git commit -F - -- app/Services/Billing/AddonPrices.php tests/Feature/Billing/StripePriceAdoptionTest.php <<'MSG' +Ask before minting a module price + +The sweep that died on 2026-07-29 left price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe +and no row here. This is the step that finds it: same money, same interval, our +metadata on it, unclaimed — taken over and brought up to date instead of +duplicated. + +The comment on the key was the root of it. It read "keyed on what the Price IS", +and a key is not that: identity is product, amount, currency and interval, and +recognising those is AdoptStripePrice's job. Stripe forgets a key after a day; +it never was the guard against a duplicate. + +A booking stays where it was sold. Adoption only ever matches an identical +amount, so a frozen booking cannot be moved by it — there is a test that says so. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +### Task 5: Die Paketseite benutzt ihn ebenso + +**Files:** +- Modify: `app/Services/Billing/PlanPrices.php:43` (Konstruktor), `:130-160` (der Anlege-Zweig in `ensure`) +- Test: `tests/Feature/Billing/StripePriceAdoptionTest.php` (angehängt) + +**Interfaces:** +- Consumes: `AdoptStripePrice::__invoke()` aus Task 3. +- Produces: `PlanPrices::__construct(StripeClient $stripe, AdoptStripePrice $adopt)`. **Geprüft: niemand konstruiert `PlanPrices` mit `new`.** + +- [ ] **Step 1: Write the failing test** + +An `tests/Feature/Billing/StripePriceAdoptionTest.php` anhängen: + +```php +use App\Models\PlanPrice; +use App\Models\StripePlanPrice; +use App\Services\Billing\PlanPrices; + +it('takes over an orphaned package price', function () { + // A catalogue mirrored once, so families have Products and rows have Prices. + app(Kernel::class)->call('stripe:sync-catalogue'); + + $row = PlanPrice::query()->firstOrFail(); + $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); + + // The register loses its row and Stripe keeps the Price: a run that died + // between the two, seen from the next run's point of view. + $orphan = (string) StripePlanPrice::query() + ->where('plan_price_id', $row->id) + ->where('reverse_charge', false) + ->value('stripe_price_id'); + StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete(); + + $before = count($this->stripe->prices); + + $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); + + expect($id)->toBe($orphan) + ->and(count($this->stripe->prices))->toBe($before) + ->and(StripePlanPrice::query() + ->where('plan_price_id', $row->id) + ->where('reverse_charge', false) + ->where('charged_cents', $charged) + ->value('stripe_price_id'))->toBe($orphan) + // The pointer for the ordinary domestic sale is written as before. + ->and((string) $row->refresh()->stripe_price_id)->toBe($orphan); +}); + +it('does not take a package price belonging to another catalogue row', function () { + app(Kernel::class)->call('stripe:sync-catalogue'); + + $row = PlanPrice::query()->firstOrFail(); + $product = (string) $row->version->family->stripe_product_id; + $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); + $interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month'; + + StripePlanPrice::query()->where('plan_price_id', $row->id)->delete(); + + // Same product, same money, same interval — and plan_price_id says it is a + // DIFFERENT row's Price. One Product carries every version and term of a + // family, so this is the ordinary case, not an exotic one. + $this->stripe->plantPrice('price_other_row', $product, $charged, (string) $row->currency, + $interval, ['plan_price_id' => (string) ($row->id + 1000), 'tax_treatment' => 'domestic']); + + $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); + + expect($id)->not->toBe('price_other_row'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: FAIL im ersten der beiden — `ensure()` legt einen zweiten Preis an, `count($this->stripe->prices)` wächst. + +- [ ] **Step 3: Wire the adoption into `PlanPrices`** + +Konstruktor (`:43`): + +```php + public function __construct( + private readonly StripeClient $stripe, + private readonly AdoptStripePrice $adopt, + ) {} +``` + +Der Zweig `if ($priceId === null)` in `ensure()` (`:135-160`): + +```php + $interval = $price->term === Subscription::TERM_YEARLY ? 'year' : 'month'; + + if ($priceId === null) { + $metadata = [ + 'plan_family' => $family->key, + 'plan_version' => (string) $version->version, + 'plan_version_id' => (string) $version->id, + 'plan_price_id' => (string) $price->id, + // Read back by anything that has a Price id and needs to know + // what kind of sale it was — and by a person looking at + // Stripe's own dashboard, where two Prices on one Product + // would otherwise differ only by an amount. + 'tax_treatment' => $treatment->reverseCharge ? 'reverse_charge' : 'domestic', + ]; + + // 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 + // 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; Stripe forgets a key after 24 hours. + idempotencyKey: "clupilot-price-{$price->id}-{$charged}" + .($treatment->reverseCharge ? '-rc' : ''), + ); + } elseif ($existing?->archived_at !== null) { +``` + +Der bestehende `elseif`-Zweig und alles darunter bleiben unverändert. + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: PASS, dreizehn Tests. + +- [ ] **Step 5: Run the billing folder** + +```bash +php artisan test tests/Feature/Billing +``` + +Expected: PASS. `ReverseChargePriceTest` ist hier der wichtigste Zeuge: `:342-347` zählt genau 16 Register-Zeilen und 16 verschiedene Preis-IDs nach einem doppelten Sync-Lauf. Bleibt das grün, hat der Wiedererkennungsschritt nichts verdoppelt und nichts zusammengelegt. + +- [ ] **Step 6: Commit** + +```bash +git add -- app/Services/Billing/PlanPrices.php tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +```bash +git commit -F - -- app/Services/Billing/PlanPrices.php tests/Feature/Billing/StripePriceAdoptionTest.php <<'MSG' +Ask before minting a package price + +Same defect, same fix, one difference: a family Product carries every version +and every term, so the amount alone does not say which row a Price belongs to. +plan_price_id in the metadata does, and it is what has to agree before a Price +is taken over. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +### Task 6: Eine Zeile pro Stripe-Preis + +**Files:** +- Create: `database/migrations/2026_07_31_210000_one_row_per_stripe_price.php` +- Modify: `docs/handoffs/2026-07-30-real-run-handoff.md:175-176` +- Test: `tests/Feature/Billing/StripePriceAdoptionTest.php` (angehängt) + +**Interfaces:** +- Consumes: nichts. +- Produces: eindeutiger Index `stripe_addon_prices_price_unique` auf `stripe_addon_prices.stripe_price_id`. + +- [ ] **Step 1: Write the failing test** + +An `tests/Feature/Billing/StripePriceAdoptionTest.php` anhängen: + +```php +use Illuminate\Database\UniqueConstraintViolationException; + +it('lets no two module rows claim one stripe price', function () { + $shared = [ + 'addon_key' => 'priority_support', 'net_cents' => 2900, 'currency' => 'EUR', + 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', + ]; + + StripeAddonPrice::query()->create([...$shared, + 'reverse_charge' => false, 'amount_cents' => 3480, 'interval' => 'month']); + + // Two rows on one Price is what Block D warned about: archiving the one + // would withdraw the Price the other is still selling. + expect(fn () => StripeAddonPrice::query()->create([...$shared, + 'reverse_charge' => true, 'amount_cents' => 2900, 'interval' => 'month'])) + ->toThrow(UniqueConstraintViolationException::class); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: FAIL — keine Ausnahme, die zweite Zeile entsteht. + +- [ ] **Step 3: Write the migration** + +`database/migrations/2026_07_31_210000_one_row_per_stripe_price.php`: + +```php +select('stripe_price_id') + ->groupBy('stripe_price_id') + ->havingRaw('count(*) > 1') + ->pluck('stripe_price_id'); + + foreach ($shared as $priceId) { + // The lowest id stays — the row that was written first, which is the + // one anything already billing is likeliest to have been reading. + $keep = DB::table('stripe_addon_prices')->where('stripe_price_id', $priceId)->min('id'); + + 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'); + }); + } +}; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +php artisan test tests/Feature/Billing/StripePriceAdoptionTest.php +``` + +Expected: PASS, vierzehn Tests. + +- [ ] ~~**Step 5: Prove the deduplication itself, on a real migration run**~~ — **NICHT AUSFÜHREN** + +~~`php artisan migrate:fresh --env=testing && php artisan migrate:status | tail -3`~~ + +> **Warnung, nicht stillschweigend gelöscht, weil die Falle es wert ist:** Dieser Befehl darf nicht laufen — es gibt in diesem Repo **keine** `.env.testing`, also greift die Verbindung aus `.env` und `migrate:fresh` löscht und baut die **echte Entwicklungsdatenbank** neu, die laufende Container benutzen. Der Index ist durch den SQLite-Migrationslauf der Suite belegt (Step 1 und Step 4); ein zweiter Beleg ist keinen Datenverlust wert. + +- [ ] **Step 6: Mark the Block D item done** + +`docs/handoffs/2026-07-30-real-run-handoff.md:175-176` ersetzen: + +```markdown +- ~~`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`. +``` + +- [ ] **Step 7: Run the full suite — this is a schema change** + +```bash +php artisan test +``` + +Expected: PASS. Eine `UniqueConstraintViolationException` in einem fremden Test bedeutet, dass dort zwei Modul-Zeilen absichtlich eine Preis-ID teilten — das ist ein Fund, der gemeldet und nicht durch Zurücknehmen des Index behoben wird. + +- [ ] **Step 8: Commit** + +```bash +git add -- database/migrations/2026_07_31_210000_one_row_per_stripe_price.php tests/Feature/Billing/StripePriceAdoptionTest.php docs/handoffs/2026-07-30-real-run-handoff.md +``` + +```bash +git commit -F - -- database/migrations/2026_07_31_210000_one_row_per_stripe_price.php tests/Feature/Billing/StripePriceAdoptionTest.php docs/handoffs/2026-07-30-real-run-handoff.md <<'MSG' +One row per Stripe price on the module side too + +The plan register has had this since 2026_07_30_110000. The module register +never did, so two rows could claim one Price and archiving the one would have +withdrawn the Price the other was still selling — Block D of the real-run +handoff. + +It is also the net under the adoption step: 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 the two apart. + +Duplicates are deleted, not archived. An archived row goes on claiming the id +and a unique index knows nothing about archived_at; the row rebuilds itself on +the next ensure(), and subscription_addons holds the Stripe id as text rather +than a foreign key, so no booking loses its price. + +Co-Authored-By: Claude Opus 5 +MSG +``` + +--- + +## Nach dem letzten Task + +- [ ] **Voller Testlauf** (nicht nur `tests/Feature/Billing`): + +```bash +php artisan test +``` + +- [ ] **Ein Review über den Gesamtdiff**, dann **eine** Fix-Runde, dann **ein** Re-Review über den Fix-Diff — und danach werden offene Befunde mit schriftlicher Begründung geparkt (R22.1). Ein zweiter Re-Review desselben Befundes ist verboten. +- [ ] **Nicht geplant, bewusst offen** (Spec §12): ein Aufräum-Kommando für Waisen, die niemand übernehmen kann. Sie stehen mit ihrer ID im Log (`stripe: left an unexplained active price alone…`). Als Folgepunkt in den Merge Request. diff --git a/docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md b/docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md new file mode 100644 index 0000000..be50139 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md @@ -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}> */ +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. diff --git a/lang/de/admin.php b/lang/de/admin.php index b8baca3..49895a1 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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.', diff --git a/lang/de/billing.php b/lang/de/billing.php index 890bef2..661d018 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -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.', diff --git a/lang/de/checkout.php b/lang/de/checkout.php index d5306cf..a015533 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -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.', ]; diff --git a/lang/de/dormant_mail.php b/lang/de/dormant_mail.php new file mode 100644 index 0000000..cbc0f15 --- /dev/null +++ b/lang/de/dormant_mail.php @@ -0,0 +1,14 @@ + '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.', +]; diff --git a/lang/de/dpa.php b/lang/de/dpa.php new file mode 100644 index 0000000..6f79b85 --- /dev/null +++ b/lang/de/dpa.php @@ -0,0 +1,18 @@ + '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', +]; diff --git a/lang/de/dpa_admin.php b/lang/de/dpa_admin.php new file mode 100644 index 0000000..20b1b2d --- /dev/null +++ b/lang/de/dpa_admin.php @@ -0,0 +1,36 @@ + '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', +]; diff --git a/lang/de/inbox.php b/lang/de/inbox.php index 94f2e58..36ad498 100644 --- a/lang/de/inbox.php +++ b/lang/de/inbox.php @@ -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', diff --git a/lang/de/integrations.php b/lang/de/integrations.php index b10ccb7..3494874 100644 --- a/lang/de/integrations.php +++ b/lang/de/integrations.php @@ -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. diff --git a/lang/de/mail_preview.php b/lang/de/mail_preview.php new file mode 100644 index 0000000..5a7633b --- /dev/null +++ b/lang/de/mail_preview.php @@ -0,0 +1,12 @@ + '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.', +]; diff --git a/lang/de/order.php b/lang/de/order.php index 0831bd7..57ab377 100644 --- a/lang/de/order.php +++ b/lang/de/order.php @@ -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, diff --git a/lang/de/plans.php b/lang/de/plans.php index 91d2848..d594780 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -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', diff --git a/lang/de/settings.php b/lang/de/settings.php index 8b14fdb..098d932 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -1,8 +1,18 @@ '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', diff --git a/lang/de/verify_email.php b/lang/de/verify_email.php index 34179b8..350eaf5 100644 --- a/lang/de/verify_email.php +++ b/lang/de/verify_email.php @@ -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.', ]; diff --git a/lang/de/withdrawal.php b/lang/de/withdrawal.php index d0daa58..5262552 100644 --- a/lang/de/withdrawal.php +++ b/lang/de/withdrawal.php @@ -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', diff --git a/lang/en/admin.php b/lang/en/admin.php index 2d53112..212d315 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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.', diff --git a/lang/en/billing.php b/lang/en/billing.php index 9693921..8fe0421 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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', diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 146113e..01d3e08 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -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.', ]; diff --git a/lang/en/dormant_mail.php b/lang/en/dormant_mail.php new file mode 100644 index 0000000..b1e1dd9 --- /dev/null +++ b/lang/en/dormant_mail.php @@ -0,0 +1,12 @@ + '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.', +]; diff --git a/lang/en/dpa.php b/lang/en/dpa.php new file mode 100644 index 0000000..4c0b3a5 --- /dev/null +++ b/lang/en/dpa.php @@ -0,0 +1,18 @@ + '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', +]; diff --git a/lang/en/dpa_admin.php b/lang/en/dpa_admin.php new file mode 100644 index 0000000..e1ee1bb --- /dev/null +++ b/lang/en/dpa_admin.php @@ -0,0 +1,36 @@ + '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', +]; diff --git a/lang/en/inbox.php b/lang/en/inbox.php index 01f742a..54fbb16 100644 --- a/lang/en/inbox.php +++ b/lang/en/inbox.php @@ -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', diff --git a/lang/en/integrations.php b/lang/en/integrations.php index 0c13a19..6d98f1f 100644 --- a/lang/en/integrations.php +++ b/lang/en/integrations.php @@ -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. diff --git a/lang/en/mail_preview.php b/lang/en/mail_preview.php new file mode 100644 index 0000000..36f4667 --- /dev/null +++ b/lang/en/mail_preview.php @@ -0,0 +1,12 @@ + '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.', +]; diff --git a/lang/en/order.php b/lang/en/order.php index 9bf9101..b055da9 100644 --- a/lang/en/order.php +++ b/lang/en/order.php @@ -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 diff --git a/lang/en/plans.php b/lang/en/plans.php index 36fce03..6fc36bf 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -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', diff --git a/lang/en/settings.php b/lang/en/settings.php index 8f33501..e72e94f 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -1,8 +1,18 @@ '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', diff --git a/lang/en/verify_email.php b/lang/en/verify_email.php index f915f95..faa578c 100644 --- a/lang/en/verify_email.php +++ b/lang/en/verify_email.php @@ -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.', ]; diff --git a/lang/en/withdrawal.php b/lang/en/withdrawal.php index a73b82a..cbdea95 100644 --- a/lang/en/withdrawal.php +++ b/lang/en/withdrawal.php @@ -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', diff --git a/resources/views/components/admin/secret-field.blade.php b/resources/views/components/admin/secret-field.blade.php index f7c818b..2fc6705 100644 --- a/resources/views/components/admin/secret-field.blade.php +++ b/resources/views/components/admin/secret-field.blade.php @@ -15,7 +15,15 @@

{{ $entry['label'] }}

-

{{ $entry['key'] }}

+ {{-- 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?". --}} +

{{ $entry['envKey'] }}

{{-- Which of the two slots THIS card reads and writes right now — diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php index 3420f0e..6952df1 100644 --- a/resources/views/components/mail/layout.blade.php +++ b/resources/views/components/mail/layout.blade.php @@ -28,18 +28,37 @@
- + {{-- 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